From fe7a7de5874ffbc4ba290e97aae6a863205dd4f0 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 4 Jun 2026 13:41:32 -0700 Subject: [PATCH 001/110] spec: openspec init --- .gitignore | 2 + docs/coding-agents/index.md | 1 + docs/development/openspec.md | 102 ++++++++++++++ docs/docs.json | 1 + openspec/config.yaml | 45 ++++++ openspec/schemas/dimos-capability/schema.yaml | 128 ++++++++++++++++++ .../dimos-capability/templates/design.md | 35 +++++ .../dimos-capability/templates/docs.md | 19 +++ .../dimos-capability/templates/proposal.md | 32 +++++ .../dimos-capability/templates/spec.md | 16 +++ .../dimos-capability/templates/tasks.md | 15 ++ 11 files changed, 396 insertions(+) create mode 100644 docs/development/openspec.md create mode 100644 openspec/config.yaml create mode 100644 openspec/schemas/dimos-capability/schema.yaml create mode 100644 openspec/schemas/dimos-capability/templates/design.md create mode 100644 openspec/schemas/dimos-capability/templates/docs.md create mode 100644 openspec/schemas/dimos-capability/templates/proposal.md create mode 100644 openspec/schemas/dimos-capability/templates/spec.md create mode 100644 openspec/schemas/dimos-capability/templates/tasks.md diff --git a/.gitignore b/.gitignore index 42bdddfa45..787163e787 100644 --- a/.gitignore +++ b/.gitignore @@ -63,8 +63,10 @@ yolo11n.pt # symlink one of .envrc.* if you'd like to use .envrc .claude +.opencode/ **/CLAUDE.md .direnv/ +.omo/ /logs diff --git a/docs/coding-agents/index.md b/docs/coding-agents/index.md index ff778ac5cf..5ac7c854a7 100644 --- a/docs/coding-agents/index.md +++ b/docs/coding-agents/index.md @@ -3,6 +3,7 @@ ├── worktrees.md (creating provisioned worktrees with `bin/worktree`) ├── style.md (code style guidelines for dimos) ├── testing.md (docs about writing tests) +├── ../development/openspec.md (OpenSpec behavior-spec workflow) ├── docs (these are docs about writing docs) │   ├── codeblocks.md │   ├── doclinks.md diff --git a/docs/development/openspec.md b/docs/development/openspec.md new file mode 100644 index 0000000000..280eb0f57e --- /dev/null +++ b/docs/development/openspec.md @@ -0,0 +1,102 @@ +# OpenSpec Workflow + +DimOS uses OpenSpec as the checked-in planning layer for behavior changes. OpenSpec artifacts live under `openspec/` and should describe what the system is supposed to do, why it is changing, and how contributors or agents should validate the work. + +## Terminology + +Keep these two meanings separate: + +- **OpenSpec capability spec**: Markdown requirements under `openspec/specs//spec.md`. These describe observable behavior and acceptance scenarios. +- **DimOS Spec**: Python Protocol/RPC contracts in files like `dimos/navigation/navigation_spec.py` or `dimos/manipulation/control/arm_driver_spec.py`. These describe module interfaces for code wiring. + +Use "OpenSpec capability spec" in prose when there is any chance of confusion. + +## Schema + +The project uses the `dimos-capability` schema configured in `openspec/config.yaml`. + +The artifact flow is: + +```text +proposal + ├── specs + ├── design + └── docs + └── tasks +``` + +| Artifact | Purpose | +|---|---| +| `proposal.md` | Intent, scope, affected DimOS surfaces, and capability impact. | +| `specs//spec.md` | Behavior-first requirements and scenarios. | +| `design.md` | Module, stream, blueprint, skill/MCP, safety, and rollout decisions. | +| `docs.md` | Documentation impact and doc validation plan. | +| `tasks.md` | Implementation, docs, verification, and manual QA checklist. | + +## When to create a change + +Create an OpenSpec change when work changes observable behavior, public CLI/API/MCP behavior, robot behavior, hardware/simulation/replay workflows, docs that users rely on, or cross-module architecture. + +Do not create a change for a purely mechanical refactor, typo fix, or internal cleanup unless it changes behavior or needs cross-session planning context. + +## Writing specs + +OpenSpec capability specs are behavior contracts, not implementation plans. + +Good spec content: + +- User- or developer-visible behavior. +- Public CLI/API/MCP tool behavior. +- Stream or message behavior that downstream modules rely on. +- Robot safety constraints and hardware/simulation/replay expectations. +- Scenarios that can be tested or manually verified. + +Avoid in specs: + +- Private class/function names. +- Generated-file mechanics. +- Library choices and wiring details. +- Step-by-step implementation tasks. + +Put those details in `design.md` or `tasks.md`. + +## Capability names + +Prefer behavior-domain names over code names. Useful starting points: + +- `module-system` +- `blueprint-composition` +- `cli-lifecycle` +- `agent-skills-mcp` +- `configuration` +- `navigation-stack` +- `manipulation-stack` +- `hardware-adapters` +- `simulation-replay` +- `documentation-system` + +Add specs progressively as changes need them. Do not try to backfill the whole project at once. + +## Validation + +Use OpenSpec validation before implementation and before archiving: + +```bash skip +openspec schema validate dimos-capability +openspec validate +openspec templates --json +``` + +For documentation changes, also run the relevant doc checks from [Writing Docs](/docs/development/writing_docs.md): + +```bash skip +md-babel-py run +``` + +When a change touches blueprint names, module-level blueprint variables, or module registry inputs, run: + +```bash skip +pytest dimos/robot/test_all_blueprints_generation.py +``` + +Then run focused tests for the changed code and manually QA through the actual surface: CLI command, MCP tool, HTTP API, simulation/replay blueprint, hardware procedure, or library driver. diff --git a/docs/docs.json b/docs/docs.json index 58da2ff6a1..f0064c9ab9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,6 +144,7 @@ "group": "Development", "pages": [ "development/conventions", + "development/openspec", "development/testing", "development/docker", "development/grid_testing", diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000000..62a72bba63 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,45 @@ +schema: dimos-capability + +context: | + DimOS is a robotics operating system for generalist robots. Modules communicate + through typed streams (`In[T]`, `Out[T]`) over LCM, SHM, ROS, DDS, or other + transports. Blueprints compose modules into runnable robot stacks. Skills are + `@skill`-annotated RPC methods exposed to agents and MCP clients. + + Terminology boundary: + - "OpenSpec spec" means a behavior specification under `openspec/specs/`. + - "DimOS Spec" means a Python Protocol/RPC contract in `*_spec.py` files, + usually inheriting `dimos.spec.utils.Spec` and `typing.Protocol`. + Keep these separate. OpenSpec specs describe observable behavior; DimOS Specs + describe code-level module interfaces. + + OpenSpec specs should capture current behavior, user/developer-visible + outcomes, public CLI/API/tool surfaces, robot safety constraints, and testable + scenarios. Put implementation choices, class names, module wiring, generated + registry updates, and rollout details in `design.md` or `tasks.md`. + + Documentation lives in: + - `docs/usage/` for user-facing concepts and APIs. + - `docs/capabilities/` for capability and platform guides. + - `docs/development/` for contributor process. + - `docs/coding-agents/` and `AGENTS.md` for coding-agent guidance. + +rules: + proposal: + - "Identify affected DimOS surfaces: modules, streams, blueprints, CLI, skills/MCP, docs, hardware, simulation, replay, or generated registries." + - Use capability names that match behavior domains, not Python class names. + - Mark hardware safety or public API/CLI changes explicitly. + specs: + - Write behavior-first requirements; avoid implementation detail unless it is externally observable. + - Every requirement must include at least one `#### Scenario:` block with concrete observable outcomes. + - Use "OpenSpec capability spec" when prose might otherwise be confused with DimOS Python `Spec` Protocols. + design: + - Call out DimOS `Spec` Protocols, adapter Protocols, blueprint composition, stream names/types, and skill/MCP exposure when relevant. + - Mention generated files and required regeneration commands, especially `pytest dimos/robot/test_all_blueprints_generation.py` for blueprint registry changes. + - Include hardware/simulation/replay assumptions and safety constraints for robot-facing work. + docs: + - List user-facing docs, contributor docs, coding-agent docs, and AGENTS.md updates required by the change. + - Include documentation validation commands for changed docs, such as `doclinks` and `md-babel-py run ` where applicable. + tasks: + - Include verification tasks for OpenSpec validation, relevant pytest targets, type checks when needed, and manual QA through the user-facing surface. + - Add registry generation tasks when blueprint names, module classes, or generated registry inputs change. diff --git a/openspec/schemas/dimos-capability/schema.yaml b/openspec/schemas/dimos-capability/schema.yaml new file mode 100644 index 0000000000..fedb7964ee --- /dev/null +++ b/openspec/schemas/dimos-capability/schema.yaml @@ -0,0 +1,128 @@ +name: dimos-capability +version: 1 +description: DimOS capability workflow - proposal → specs/design/docs → tasks +artifacts: + - id: proposal + generates: proposal.md + description: DimOS change proposal covering intent, scope, capability impact, and affected robot/software surfaces + template: proposal.md + instruction: | + Create the proposal document that establishes WHY this change is needed and what DimOS behavior it affects. + + Sections: + - **Why**: 1-2 concise paragraphs on the problem or opportunity. Explain why the change matters now. + - **What Changes**: Bullet list of added, modified, or removed behavior. Mark public API/CLI or hardware-safety breaking changes with **BREAKING**. + - **Affected DimOS Surfaces**: Identify modules, streams, blueprints, CLI commands, skills/MCP tools, docs, hardware, simulation, replay, generated registries, or external protocols touched by the change. + - **Capabilities**: Identify which OpenSpec capability specs will be created or modified: + - **New Capabilities**: List behavior domains introduced by the change. Each becomes `specs//spec.md`. Use kebab-case names (for example, `agent-skills-mcp`, `blueprint-composition`, `manipulation-stack`). + - **Modified Capabilities**: List existing `openspec/specs//` entries whose requirements change. Only include spec-level behavior changes, not implementation-only refactors. + - **Impact**: Summarize user/developer impact, compatibility risks, dependency changes, documentation updates, and test/QA scope. + + Keep proposals concise. Do not include line-by-line implementation details; put architecture and rollout decisions in `design.md`. + requires: [] + - id: specs + generates: specs/**/*.md + description: Behavior-first OpenSpec capability delta specifications + template: spec.md + instruction: | + Create OpenSpec capability specs that define WHAT DimOS should do, not how it is implemented. + + Create one delta spec file per capability listed in proposal.md: + - New capabilities: use `specs//spec.md` with the exact kebab-case name from the proposal. + - Modified capabilities: use the existing folder from `openspec/specs//`. + + Use these delta sections as `##` headers: + - **ADDED Requirements**: New externally observable behavior. + - **MODIFIED Requirements**: Changed behavior. Include the full updated requirement block, not a partial patch. + - **REMOVED Requirements**: Deprecated behavior. Include **Reason** and **Migration**. + - **RENAMED Requirements**: Name-only changes. Use FROM:/TO: format. + + Requirement format: + - Use `### Requirement: `. + - Use SHALL/MUST for normative requirements. + - Include at least one `#### Scenario: ` per requirement. Scenario headings MUST use exactly four `#` characters. + - Prefer `- **GIVEN**`, `- **WHEN**`, `- **THEN**`, and `- **AND**` bullets. + - Cover happy path plus meaningful edge/error/safety cases. + + DimOS-specific guidance: + - Specify user/developer-visible behavior, robot outcomes, CLI behavior, skill/MCP tool behavior, stream contracts, safety constraints, and compatibility expectations. + - Avoid Python class names, private module internals, transport implementation choices, and generated-file details unless those details are observable API contracts. + - Use "OpenSpec capability spec" in prose when needed to avoid confusion with DimOS Python `Spec` Protocols. + - If the behavior only changes implementation and not observable requirements, do not create a spec delta. + requires: + - proposal + - id: design + generates: design.md + description: DimOS technical design and architecture decisions + template: design.md + instruction: | + Create the design document that explains HOW the change should be implemented in DimOS. + + Include design.md for cross-module changes, new robot/hardware integration, new public interfaces, new dependencies, safety-sensitive behavior, generated registry changes, or unclear architecture. + + Sections: + - **Context**: Current state, relevant modules/blueprints/docs, and constraints. + - **Goals / Non-Goals**: What the design achieves and explicitly excludes. + - **DimOS Architecture**: Modules, streams, transports, blueprints, RPC/module refs, DimOS `Spec` Protocols, adapter Protocols, skills/MCP exposure, CLI entry points, and generated registries involved. + - **Decisions**: Key choices with rationale and alternatives considered. + - **Safety / Simulation / Replay**: Hardware assumptions, sim/replay behavior, safety constraints, and manual QA surface. + - **Risks / Trade-offs**: Known risks and mitigations. + - **Migration / Rollout**: Compatibility, generated files, docs, and deployment steps. + - **Open Questions**: Outstanding decisions or unknowns. + + Reference proposal.md for intent and specs for behavior. Keep line-by-line work in tasks.md. + requires: + - proposal + - id: docs + generates: docs.md + description: Documentation impact plan for user, contributor, and coding-agent docs + template: docs.md + instruction: | + Create the documentation impact plan for the change. + + Sections: + - **User-Facing Docs**: Updates under `docs/usage/`, `docs/capabilities/`, `docs/platforms/`, or README files. + - **Contributor Docs**: Updates under `docs/development/`. + - **Coding-Agent Docs**: Updates under `docs/coding-agents/` or `AGENTS.md`. + - **Doc Validation**: Commands needed for changed docs, such as `doclinks`, `md-babel-py run `, and `bin/gen-diagrams`. + - **No Docs Needed**: If no docs are needed, explain why. + + Match `docs/development/writing_docs.md`: contributor-only docs belong in `docs/development`; user-facing behavior belongs in `docs/usage` or `docs/capabilities`. + requires: + - proposal + - id: tasks + generates: tasks.md + description: Implementation, validation, docs, and manual-QA checklist + template: tasks.md + instruction: | + Create the implementation checklist. The apply phase parses checkbox format, so every actionable task MUST use `- [ ]`. + + Guidelines: + - Group tasks under numbered `##` headings. + - Each task must be `- [ ] X.Y Task description`. + - Keep tasks small enough to complete in one focused session. + - Order tasks by dependency. + - Include docs and validation tasks from docs.md. + - Include generated registry tasks when blueprints or module registry inputs change. + - Include manual QA through the actual user surface: CLI, TUI, HTTP API, MCP tool, simulation/replay blueprint, hardware procedure, or library driver. + + Typical DimOS validation tasks: + - Run `openspec validate `. + - Run focused pytest targets for changed modules. + - Run `pytest dimos/robot/test_all_blueprints_generation.py` when blueprint registry output may change. + - Run docs validation commands for changed docs. + - Run lints/types when the touched area requires them. + + Reference specs for WHAT, design for HOW, and docs.md for documentation work. + requires: + - specs + - design + - docs +apply: + requires: + - tasks + tracks: tasks.md + instruction: | + Read proposal.md, specs, design.md, docs.md, and tasks.md before editing code. + Work through pending tasks, mark checkboxes complete as they finish, and keep artifacts current when implementation changes the plan. + Verify with OpenSpec validation, focused tests, docs checks, and manual QA through the relevant DimOS surface. diff --git a/openspec/schemas/dimos-capability/templates/design.md b/openspec/schemas/dimos-capability/templates/design.md new file mode 100644 index 0000000000..25031ceb8b --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/design.md @@ -0,0 +1,35 @@ +## Context + + + +## Goals / Non-Goals + +**Goals:** + + +**Non-Goals:** + + +## DimOS Architecture + + + +## Decisions + + + +## Safety / Simulation / Replay + + + +## Risks / Trade-offs + + + +## Migration / Rollout + + + +## Open Questions + + diff --git a/openspec/schemas/dimos-capability/templates/docs.md b/openspec/schemas/dimos-capability/templates/docs.md new file mode 100644 index 0000000000..d274aed653 --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/docs.md @@ -0,0 +1,19 @@ +## User-Facing Docs + + + +## Contributor Docs + + + +## Coding-Agent Docs + + + +## Doc Validation + + + +## No Docs Needed + + diff --git a/openspec/schemas/dimos-capability/templates/proposal.md b/openspec/schemas/dimos-capability/templates/proposal.md new file mode 100644 index 0000000000..98d409e8de --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/proposal.md @@ -0,0 +1,32 @@ +## Why + + + +## What Changes + + + +## Affected DimOS Surfaces + + +- Modules/streams: +- Blueprints/CLI: +- Skills/MCP: +- Hardware/simulation/replay: +- Docs/generated registries: + +## Capabilities + +### New Capabilities + +- ``: + +### Modified Capabilities + +- ``: + +## Impact + + diff --git a/openspec/schemas/dimos-capability/templates/spec.md b/openspec/schemas/dimos-capability/templates/spec.md new file mode 100644 index 0000000000..afc0c1ff58 --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: + + +#### Scenario: +- **GIVEN** +- **WHEN** +- **THEN** +- **AND** + + diff --git a/openspec/schemas/dimos-capability/templates/tasks.md b/openspec/schemas/dimos-capability/templates/tasks.md new file mode 100644 index 0000000000..b38fcdfabb --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [ ] 1.1 +- [ ] 1.2 + +## 2. Documentation + +- [ ] 2.1 + +## 3. Verification + +- [ ] 3.1 Run `openspec validate ` +- [ ] 3.2 Run focused tests for changed code +- [ ] 3.3 Run docs validation commands for changed docs +- [ ] 3.4 Manually QA through the relevant DimOS surface (CLI, MCP, simulation/replay, hardware procedure, HTTP API, or library driver) From d67a77039630fb897f2af6dfde69e4bb257c41f8 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 4 Jun 2026 13:41:32 -0700 Subject: [PATCH 002/110] spec: openspec init --- .gitignore | 2 + docs/coding-agents/index.md | 1 + docs/development/openspec.md | 102 ++++++++++++++ docs/docs.json | 1 + openspec/config.yaml | 45 ++++++ openspec/schemas/dimos-capability/schema.yaml | 128 ++++++++++++++++++ .../dimos-capability/templates/design.md | 35 +++++ .../dimos-capability/templates/docs.md | 19 +++ .../dimos-capability/templates/proposal.md | 32 +++++ .../dimos-capability/templates/spec.md | 16 +++ .../dimos-capability/templates/tasks.md | 15 ++ 11 files changed, 396 insertions(+) create mode 100644 docs/development/openspec.md create mode 100644 openspec/config.yaml create mode 100644 openspec/schemas/dimos-capability/schema.yaml create mode 100644 openspec/schemas/dimos-capability/templates/design.md create mode 100644 openspec/schemas/dimos-capability/templates/docs.md create mode 100644 openspec/schemas/dimos-capability/templates/proposal.md create mode 100644 openspec/schemas/dimos-capability/templates/spec.md create mode 100644 openspec/schemas/dimos-capability/templates/tasks.md diff --git a/.gitignore b/.gitignore index 42bdddfa45..787163e787 100644 --- a/.gitignore +++ b/.gitignore @@ -63,8 +63,10 @@ yolo11n.pt # symlink one of .envrc.* if you'd like to use .envrc .claude +.opencode/ **/CLAUDE.md .direnv/ +.omo/ /logs diff --git a/docs/coding-agents/index.md b/docs/coding-agents/index.md index ff778ac5cf..5ac7c854a7 100644 --- a/docs/coding-agents/index.md +++ b/docs/coding-agents/index.md @@ -3,6 +3,7 @@ ├── worktrees.md (creating provisioned worktrees with `bin/worktree`) ├── style.md (code style guidelines for dimos) ├── testing.md (docs about writing tests) +├── ../development/openspec.md (OpenSpec behavior-spec workflow) ├── docs (these are docs about writing docs) │   ├── codeblocks.md │   ├── doclinks.md diff --git a/docs/development/openspec.md b/docs/development/openspec.md new file mode 100644 index 0000000000..280eb0f57e --- /dev/null +++ b/docs/development/openspec.md @@ -0,0 +1,102 @@ +# OpenSpec Workflow + +DimOS uses OpenSpec as the checked-in planning layer for behavior changes. OpenSpec artifacts live under `openspec/` and should describe what the system is supposed to do, why it is changing, and how contributors or agents should validate the work. + +## Terminology + +Keep these two meanings separate: + +- **OpenSpec capability spec**: Markdown requirements under `openspec/specs//spec.md`. These describe observable behavior and acceptance scenarios. +- **DimOS Spec**: Python Protocol/RPC contracts in files like `dimos/navigation/navigation_spec.py` or `dimos/manipulation/control/arm_driver_spec.py`. These describe module interfaces for code wiring. + +Use "OpenSpec capability spec" in prose when there is any chance of confusion. + +## Schema + +The project uses the `dimos-capability` schema configured in `openspec/config.yaml`. + +The artifact flow is: + +```text +proposal + ├── specs + ├── design + └── docs + └── tasks +``` + +| Artifact | Purpose | +|---|---| +| `proposal.md` | Intent, scope, affected DimOS surfaces, and capability impact. | +| `specs//spec.md` | Behavior-first requirements and scenarios. | +| `design.md` | Module, stream, blueprint, skill/MCP, safety, and rollout decisions. | +| `docs.md` | Documentation impact and doc validation plan. | +| `tasks.md` | Implementation, docs, verification, and manual QA checklist. | + +## When to create a change + +Create an OpenSpec change when work changes observable behavior, public CLI/API/MCP behavior, robot behavior, hardware/simulation/replay workflows, docs that users rely on, or cross-module architecture. + +Do not create a change for a purely mechanical refactor, typo fix, or internal cleanup unless it changes behavior or needs cross-session planning context. + +## Writing specs + +OpenSpec capability specs are behavior contracts, not implementation plans. + +Good spec content: + +- User- or developer-visible behavior. +- Public CLI/API/MCP tool behavior. +- Stream or message behavior that downstream modules rely on. +- Robot safety constraints and hardware/simulation/replay expectations. +- Scenarios that can be tested or manually verified. + +Avoid in specs: + +- Private class/function names. +- Generated-file mechanics. +- Library choices and wiring details. +- Step-by-step implementation tasks. + +Put those details in `design.md` or `tasks.md`. + +## Capability names + +Prefer behavior-domain names over code names. Useful starting points: + +- `module-system` +- `blueprint-composition` +- `cli-lifecycle` +- `agent-skills-mcp` +- `configuration` +- `navigation-stack` +- `manipulation-stack` +- `hardware-adapters` +- `simulation-replay` +- `documentation-system` + +Add specs progressively as changes need them. Do not try to backfill the whole project at once. + +## Validation + +Use OpenSpec validation before implementation and before archiving: + +```bash skip +openspec schema validate dimos-capability +openspec validate +openspec templates --json +``` + +For documentation changes, also run the relevant doc checks from [Writing Docs](/docs/development/writing_docs.md): + +```bash skip +md-babel-py run +``` + +When a change touches blueprint names, module-level blueprint variables, or module registry inputs, run: + +```bash skip +pytest dimos/robot/test_all_blueprints_generation.py +``` + +Then run focused tests for the changed code and manually QA through the actual surface: CLI command, MCP tool, HTTP API, simulation/replay blueprint, hardware procedure, or library driver. diff --git a/docs/docs.json b/docs/docs.json index 58da2ff6a1..f0064c9ab9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,6 +144,7 @@ "group": "Development", "pages": [ "development/conventions", + "development/openspec", "development/testing", "development/docker", "development/grid_testing", diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000000..62a72bba63 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,45 @@ +schema: dimos-capability + +context: | + DimOS is a robotics operating system for generalist robots. Modules communicate + through typed streams (`In[T]`, `Out[T]`) over LCM, SHM, ROS, DDS, or other + transports. Blueprints compose modules into runnable robot stacks. Skills are + `@skill`-annotated RPC methods exposed to agents and MCP clients. + + Terminology boundary: + - "OpenSpec spec" means a behavior specification under `openspec/specs/`. + - "DimOS Spec" means a Python Protocol/RPC contract in `*_spec.py` files, + usually inheriting `dimos.spec.utils.Spec` and `typing.Protocol`. + Keep these separate. OpenSpec specs describe observable behavior; DimOS Specs + describe code-level module interfaces. + + OpenSpec specs should capture current behavior, user/developer-visible + outcomes, public CLI/API/tool surfaces, robot safety constraints, and testable + scenarios. Put implementation choices, class names, module wiring, generated + registry updates, and rollout details in `design.md` or `tasks.md`. + + Documentation lives in: + - `docs/usage/` for user-facing concepts and APIs. + - `docs/capabilities/` for capability and platform guides. + - `docs/development/` for contributor process. + - `docs/coding-agents/` and `AGENTS.md` for coding-agent guidance. + +rules: + proposal: + - "Identify affected DimOS surfaces: modules, streams, blueprints, CLI, skills/MCP, docs, hardware, simulation, replay, or generated registries." + - Use capability names that match behavior domains, not Python class names. + - Mark hardware safety or public API/CLI changes explicitly. + specs: + - Write behavior-first requirements; avoid implementation detail unless it is externally observable. + - Every requirement must include at least one `#### Scenario:` block with concrete observable outcomes. + - Use "OpenSpec capability spec" when prose might otherwise be confused with DimOS Python `Spec` Protocols. + design: + - Call out DimOS `Spec` Protocols, adapter Protocols, blueprint composition, stream names/types, and skill/MCP exposure when relevant. + - Mention generated files and required regeneration commands, especially `pytest dimos/robot/test_all_blueprints_generation.py` for blueprint registry changes. + - Include hardware/simulation/replay assumptions and safety constraints for robot-facing work. + docs: + - List user-facing docs, contributor docs, coding-agent docs, and AGENTS.md updates required by the change. + - Include documentation validation commands for changed docs, such as `doclinks` and `md-babel-py run ` where applicable. + tasks: + - Include verification tasks for OpenSpec validation, relevant pytest targets, type checks when needed, and manual QA through the user-facing surface. + - Add registry generation tasks when blueprint names, module classes, or generated registry inputs change. diff --git a/openspec/schemas/dimos-capability/schema.yaml b/openspec/schemas/dimos-capability/schema.yaml new file mode 100644 index 0000000000..fedb7964ee --- /dev/null +++ b/openspec/schemas/dimos-capability/schema.yaml @@ -0,0 +1,128 @@ +name: dimos-capability +version: 1 +description: DimOS capability workflow - proposal → specs/design/docs → tasks +artifacts: + - id: proposal + generates: proposal.md + description: DimOS change proposal covering intent, scope, capability impact, and affected robot/software surfaces + template: proposal.md + instruction: | + Create the proposal document that establishes WHY this change is needed and what DimOS behavior it affects. + + Sections: + - **Why**: 1-2 concise paragraphs on the problem or opportunity. Explain why the change matters now. + - **What Changes**: Bullet list of added, modified, or removed behavior. Mark public API/CLI or hardware-safety breaking changes with **BREAKING**. + - **Affected DimOS Surfaces**: Identify modules, streams, blueprints, CLI commands, skills/MCP tools, docs, hardware, simulation, replay, generated registries, or external protocols touched by the change. + - **Capabilities**: Identify which OpenSpec capability specs will be created or modified: + - **New Capabilities**: List behavior domains introduced by the change. Each becomes `specs//spec.md`. Use kebab-case names (for example, `agent-skills-mcp`, `blueprint-composition`, `manipulation-stack`). + - **Modified Capabilities**: List existing `openspec/specs//` entries whose requirements change. Only include spec-level behavior changes, not implementation-only refactors. + - **Impact**: Summarize user/developer impact, compatibility risks, dependency changes, documentation updates, and test/QA scope. + + Keep proposals concise. Do not include line-by-line implementation details; put architecture and rollout decisions in `design.md`. + requires: [] + - id: specs + generates: specs/**/*.md + description: Behavior-first OpenSpec capability delta specifications + template: spec.md + instruction: | + Create OpenSpec capability specs that define WHAT DimOS should do, not how it is implemented. + + Create one delta spec file per capability listed in proposal.md: + - New capabilities: use `specs//spec.md` with the exact kebab-case name from the proposal. + - Modified capabilities: use the existing folder from `openspec/specs//`. + + Use these delta sections as `##` headers: + - **ADDED Requirements**: New externally observable behavior. + - **MODIFIED Requirements**: Changed behavior. Include the full updated requirement block, not a partial patch. + - **REMOVED Requirements**: Deprecated behavior. Include **Reason** and **Migration**. + - **RENAMED Requirements**: Name-only changes. Use FROM:/TO: format. + + Requirement format: + - Use `### Requirement: `. + - Use SHALL/MUST for normative requirements. + - Include at least one `#### Scenario: ` per requirement. Scenario headings MUST use exactly four `#` characters. + - Prefer `- **GIVEN**`, `- **WHEN**`, `- **THEN**`, and `- **AND**` bullets. + - Cover happy path plus meaningful edge/error/safety cases. + + DimOS-specific guidance: + - Specify user/developer-visible behavior, robot outcomes, CLI behavior, skill/MCP tool behavior, stream contracts, safety constraints, and compatibility expectations. + - Avoid Python class names, private module internals, transport implementation choices, and generated-file details unless those details are observable API contracts. + - Use "OpenSpec capability spec" in prose when needed to avoid confusion with DimOS Python `Spec` Protocols. + - If the behavior only changes implementation and not observable requirements, do not create a spec delta. + requires: + - proposal + - id: design + generates: design.md + description: DimOS technical design and architecture decisions + template: design.md + instruction: | + Create the design document that explains HOW the change should be implemented in DimOS. + + Include design.md for cross-module changes, new robot/hardware integration, new public interfaces, new dependencies, safety-sensitive behavior, generated registry changes, or unclear architecture. + + Sections: + - **Context**: Current state, relevant modules/blueprints/docs, and constraints. + - **Goals / Non-Goals**: What the design achieves and explicitly excludes. + - **DimOS Architecture**: Modules, streams, transports, blueprints, RPC/module refs, DimOS `Spec` Protocols, adapter Protocols, skills/MCP exposure, CLI entry points, and generated registries involved. + - **Decisions**: Key choices with rationale and alternatives considered. + - **Safety / Simulation / Replay**: Hardware assumptions, sim/replay behavior, safety constraints, and manual QA surface. + - **Risks / Trade-offs**: Known risks and mitigations. + - **Migration / Rollout**: Compatibility, generated files, docs, and deployment steps. + - **Open Questions**: Outstanding decisions or unknowns. + + Reference proposal.md for intent and specs for behavior. Keep line-by-line work in tasks.md. + requires: + - proposal + - id: docs + generates: docs.md + description: Documentation impact plan for user, contributor, and coding-agent docs + template: docs.md + instruction: | + Create the documentation impact plan for the change. + + Sections: + - **User-Facing Docs**: Updates under `docs/usage/`, `docs/capabilities/`, `docs/platforms/`, or README files. + - **Contributor Docs**: Updates under `docs/development/`. + - **Coding-Agent Docs**: Updates under `docs/coding-agents/` or `AGENTS.md`. + - **Doc Validation**: Commands needed for changed docs, such as `doclinks`, `md-babel-py run `, and `bin/gen-diagrams`. + - **No Docs Needed**: If no docs are needed, explain why. + + Match `docs/development/writing_docs.md`: contributor-only docs belong in `docs/development`; user-facing behavior belongs in `docs/usage` or `docs/capabilities`. + requires: + - proposal + - id: tasks + generates: tasks.md + description: Implementation, validation, docs, and manual-QA checklist + template: tasks.md + instruction: | + Create the implementation checklist. The apply phase parses checkbox format, so every actionable task MUST use `- [ ]`. + + Guidelines: + - Group tasks under numbered `##` headings. + - Each task must be `- [ ] X.Y Task description`. + - Keep tasks small enough to complete in one focused session. + - Order tasks by dependency. + - Include docs and validation tasks from docs.md. + - Include generated registry tasks when blueprints or module registry inputs change. + - Include manual QA through the actual user surface: CLI, TUI, HTTP API, MCP tool, simulation/replay blueprint, hardware procedure, or library driver. + + Typical DimOS validation tasks: + - Run `openspec validate `. + - Run focused pytest targets for changed modules. + - Run `pytest dimos/robot/test_all_blueprints_generation.py` when blueprint registry output may change. + - Run docs validation commands for changed docs. + - Run lints/types when the touched area requires them. + + Reference specs for WHAT, design for HOW, and docs.md for documentation work. + requires: + - specs + - design + - docs +apply: + requires: + - tasks + tracks: tasks.md + instruction: | + Read proposal.md, specs, design.md, docs.md, and tasks.md before editing code. + Work through pending tasks, mark checkboxes complete as they finish, and keep artifacts current when implementation changes the plan. + Verify with OpenSpec validation, focused tests, docs checks, and manual QA through the relevant DimOS surface. diff --git a/openspec/schemas/dimos-capability/templates/design.md b/openspec/schemas/dimos-capability/templates/design.md new file mode 100644 index 0000000000..25031ceb8b --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/design.md @@ -0,0 +1,35 @@ +## Context + + + +## Goals / Non-Goals + +**Goals:** + + +**Non-Goals:** + + +## DimOS Architecture + + + +## Decisions + + + +## Safety / Simulation / Replay + + + +## Risks / Trade-offs + + + +## Migration / Rollout + + + +## Open Questions + + diff --git a/openspec/schemas/dimos-capability/templates/docs.md b/openspec/schemas/dimos-capability/templates/docs.md new file mode 100644 index 0000000000..d274aed653 --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/docs.md @@ -0,0 +1,19 @@ +## User-Facing Docs + + + +## Contributor Docs + + + +## Coding-Agent Docs + + + +## Doc Validation + + + +## No Docs Needed + + diff --git a/openspec/schemas/dimos-capability/templates/proposal.md b/openspec/schemas/dimos-capability/templates/proposal.md new file mode 100644 index 0000000000..98d409e8de --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/proposal.md @@ -0,0 +1,32 @@ +## Why + + + +## What Changes + + + +## Affected DimOS Surfaces + + +- Modules/streams: +- Blueprints/CLI: +- Skills/MCP: +- Hardware/simulation/replay: +- Docs/generated registries: + +## Capabilities + +### New Capabilities + +- ``: + +### Modified Capabilities + +- ``: + +## Impact + + diff --git a/openspec/schemas/dimos-capability/templates/spec.md b/openspec/schemas/dimos-capability/templates/spec.md new file mode 100644 index 0000000000..afc0c1ff58 --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: + + +#### Scenario: +- **GIVEN** +- **WHEN** +- **THEN** +- **AND** + + diff --git a/openspec/schemas/dimos-capability/templates/tasks.md b/openspec/schemas/dimos-capability/templates/tasks.md new file mode 100644 index 0000000000..b38fcdfabb --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [ ] 1.1 +- [ ] 1.2 + +## 2. Documentation + +- [ ] 2.1 + +## 3. Verification + +- [ ] 3.1 Run `openspec validate ` +- [ ] 3.2 Run focused tests for changed code +- [ ] 3.3 Run docs validation commands for changed docs +- [ ] 3.4 Manually QA through the relevant DimOS surface (CLI, MCP, simulation/replay, hardware procedure, HTTP API, or library driver) From 99916628eede4fcef89729b69e60599b496e23f0 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 4 Jun 2026 23:48:40 -0700 Subject: [PATCH 003/110] feat: add dm motor openarm adapter --- .../current_position_hold_task/__init__.py | 13 + .../__registry__.py | 17 + .../current_position_hold_task.py | 124 ++++ dimos/control/test_control.py | 58 ++ .../manipulators/dm_motor_arm/__init__.py | 18 + .../manipulators/dm_motor_arm/adapter.py | 541 ++++++++++++++++++ .../manipulators/dm_motor_arm/test_adapter.py | 364 ++++++++++++ dimos/robot/all_blueprints.py | 2 + dimos/robot/catalog/openarm.py | 11 +- .../robot/manipulators/openarm/blueprints.py | 45 +- .../manipulation/openarm_integration.md | 48 +- .../add-dm-motor-arm-adapter/.openspec.yaml | 2 + .../add-dm-motor-arm-adapter/design.md | 121 ++++ .../changes/add-dm-motor-arm-adapter/docs.md | 30 + .../add-dm-motor-arm-adapter/proposal.md | 40 ++ .../dm-motor-manipulator-adapters/spec.md | 56 ++ .../gravity-compensation-control/spec.md | 43 ++ .../specs/manipulation-stack/spec.md | 31 + .../changes/add-dm-motor-arm-adapter/tasks.md | 50 ++ 19 files changed, 1602 insertions(+), 12 deletions(-) create mode 100644 dimos/control/tasks/current_position_hold_task/__init__.py create mode 100644 dimos/control/tasks/current_position_hold_task/__registry__.py create mode 100644 dimos/control/tasks/current_position_hold_task/current_position_hold_task.py create mode 100644 dimos/hardware/manipulators/dm_motor_arm/__init__.py create mode 100644 dimos/hardware/manipulators/dm_motor_arm/adapter.py create mode 100644 dimos/hardware/manipulators/dm_motor_arm/test_adapter.py create mode 100644 openspec/changes/add-dm-motor-arm-adapter/.openspec.yaml create mode 100644 openspec/changes/add-dm-motor-arm-adapter/design.md create mode 100644 openspec/changes/add-dm-motor-arm-adapter/docs.md create mode 100644 openspec/changes/add-dm-motor-arm-adapter/proposal.md create mode 100644 openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md create mode 100644 openspec/changes/add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md create mode 100644 openspec/changes/add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md create mode 100644 openspec/changes/add-dm-motor-arm-adapter/tasks.md diff --git a/dimos/control/tasks/current_position_hold_task/__init__.py b/dimos/control/tasks/current_position_hold_task/__init__.py new file mode 100644 index 0000000000..bc1a2ce5cc --- /dev/null +++ b/dimos/control/tasks/current_position_hold_task/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/dimos/control/tasks/current_position_hold_task/__registry__.py b/dimos/control/tasks/current_position_hold_task/__registry__.py new file mode 100644 index 0000000000..a2f1ec89db --- /dev/null +++ b/dimos/control/tasks/current_position_hold_task/__registry__.py @@ -0,0 +1,17 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +TASK_FACTORIES = { + "current_position_hold": "dimos.control.tasks.current_position_hold_task.current_position_hold_task:create_task", +} diff --git a/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py b/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py new file mode 100644 index 0000000000..c508e1b7f3 --- /dev/null +++ b/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py @@ -0,0 +1,124 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Current-position hold task for hardware write-path bring-up. + +This task continuously outputs the current measured joint positions as +SERVO_POSITION commands. It is intended for controlled hardware tests where the +adapter should actively write hold frames without requiring an external +trajectory command. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from dimos.control.task import ( + BaseControlTask, + ControlMode, + CoordinatorState, + JointCommandOutput, + ResourceClaim, +) +from dimos.protocol.service.spec import BaseConfig +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + + +@dataclass +class CurrentPositionHoldTaskConfig: + joint_names: list[str] + priority: int = 5 + + +class CurrentPositionHoldTask(BaseControlTask): + """Hold the current measured joint positions every coordinator tick.""" + + def __init__(self, name: str, config: CurrentPositionHoldTaskConfig) -> None: + if not config.joint_names: + raise ValueError(f"CurrentPositionHoldTask '{name}' requires at least one joint") + self._name = name + self._config = config + self._joint_names = frozenset(config.joint_names) + self._joint_names_list = list(config.joint_names) + self._active = False + logger.info( + f"CurrentPositionHoldTask {name} initialized for joints: {config.joint_names}" + ) + + @property + def name(self) -> str: + return self._name + + def claim(self) -> ResourceClaim: + return ResourceClaim( + joints=self._joint_names, + priority=self._config.priority, + mode=ControlMode.SERVO_POSITION, + ) + + def is_active(self) -> bool: + return self._active + + def compute(self, state: CoordinatorState) -> JointCommandOutput | None: + if not self._active: + return None + positions: list[float] = [] + for joint_name in self._joint_names_list: + position = state.joints.get_position(joint_name) + if position is None: + return None + positions.append(position) + return JointCommandOutput( + joint_names=self._joint_names_list, + positions=positions, + mode=ControlMode.SERVO_POSITION, + ) + + def on_preempted(self, by_task: str, joints: frozenset[str]) -> None: + if joints & self._joint_names: + logger.warning( + f"CurrentPositionHoldTask {self._name} preempted by {by_task} on joints {joints}" + ) + + def start(self) -> None: + self._active = True + logger.info(f"CurrentPositionHoldTask {self._name} started") + + def stop(self) -> None: + self._active = False + logger.info(f"CurrentPositionHoldTask {self._name} stopped") + + +class CurrentPositionHoldTaskParams(BaseConfig): + pass + + +def create_task(cfg: Any, hardware: Any) -> CurrentPositionHoldTask: + CurrentPositionHoldTaskParams.model_validate(cfg.params) + return CurrentPositionHoldTask( + cfg.name, + CurrentPositionHoldTaskConfig( + joint_names=cfg.joint_names, + priority=cfg.priority, + ), + ) + + +__all__ = [ + "CurrentPositionHoldTask", + "CurrentPositionHoldTaskConfig", +] diff --git a/dimos/control/test_control.py b/dimos/control/test_control.py index ce190b4f61..8e1c665710 100644 --- a/dimos/control/test_control.py +++ b/dimos/control/test_control.py @@ -31,6 +31,10 @@ JointStateSnapshot, ResourceClaim, ) +from dimos.control.tasks.current_position_hold_task.current_position_hold_task import ( + CurrentPositionHoldTask, + CurrentPositionHoldTaskConfig, +) from dimos.control.tasks.trajectory_task.trajectory_task import ( JointTrajectoryTask, JointTrajectoryTaskConfig, @@ -517,3 +521,57 @@ def test_full_trajectory_execution(self, mock_adapter): assert traj_task.get_state() == TrajectoryState.COMPLETED assert mock_adapter.write_joint_positions.call_count > 0 + + +class TestCurrentPositionHoldTask: + def test_initial_state(self) -> None: + task = CurrentPositionHoldTask( + name="hold", + config=CurrentPositionHoldTaskConfig( + joint_names=["arm/joint1", "arm/joint2"], + priority=5, + ), + ) + assert not task.is_active() + claim = task.claim() + assert claim.joints == frozenset({"arm/joint1", "arm/joint2"}) + assert claim.priority == 5 + assert claim.mode == ControlMode.SERVO_POSITION + + def test_outputs_current_positions_when_started(self) -> None: + task = CurrentPositionHoldTask( + name="hold", + config=CurrentPositionHoldTaskConfig( + joint_names=["arm/joint1", "arm/joint2"], + priority=5, + ), + ) + task.start() + state = CoordinatorState( + joints=JointStateSnapshot( + joint_positions={"arm/joint1": 0.1, "arm/joint2": -0.2}, + ), + t_now=1.0, + dt=0.01, + ) + output = task.compute(state) + assert output is not None + assert output.mode == ControlMode.SERVO_POSITION + assert output.joint_names == ["arm/joint1", "arm/joint2"] + assert output.positions == pytest.approx([0.1, -0.2]) + + def test_skips_output_until_all_positions_exist(self) -> None: + task = CurrentPositionHoldTask( + name="hold", + config=CurrentPositionHoldTaskConfig( + joint_names=["arm/joint1", "arm/joint2"], + priority=5, + ), + ) + task.start() + state = CoordinatorState( + joints=JointStateSnapshot(joint_positions={"arm/joint1": 0.1}), + t_now=1.0, + dt=0.01, + ) + assert task.compute(state) is None diff --git a/dimos/hardware/manipulators/dm_motor_arm/__init__.py b/dimos/hardware/manipulators/dm_motor_arm/__init__.py new file mode 100644 index 0000000000..5306d4a602 --- /dev/null +++ b/dimos/hardware/manipulators/dm_motor_arm/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from dimos.hardware.manipulators.dm_motor_arm.adapter import DMMotorArm + +__all__ = ["DMMotorArm"] diff --git a/dimos/hardware/manipulators/dm_motor_arm/adapter.py b/dimos/hardware/manipulators/dm_motor_arm/adapter.py new file mode 100644 index 0000000000..7defe04fe4 --- /dev/null +++ b/dimos/hardware/manipulators/dm_motor_arm/adapter.py @@ -0,0 +1,541 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +from dataclasses import dataclass +import importlib +from pathlib import Path +import time +from types import ModuleType +from typing import TYPE_CHECKING, Any + +import numpy as np + +from dimos.hardware.manipulators.spec import ControlMode, JointLimits, ManipulatorInfo +from dimos.utils.logging_config import setup_logger + +if TYPE_CHECKING: + from dimos.hardware.manipulators.registry import AdapterRegistry + +logger = setup_logger() + + +class DMMotorBindingUnavailableError(RuntimeError): + pass + + +@dataclass(frozen=True) +class DMMotorSpecConfig: + name: str + type: str | int + send_id: int + recv_id: int + + +_DEFAULT_OPENARM_MOTORS: tuple[DMMotorSpecConfig, ...] = ( + DMMotorSpecConfig("joint1", "DM8006", 0x01, 0x11), + DMMotorSpecConfig("joint2", "DM8006", 0x02, 0x12), + DMMotorSpecConfig("joint3", "DM4340", 0x03, 0x13), + DMMotorSpecConfig("joint4", "DM4340", 0x04, 0x14), + DMMotorSpecConfig("joint5", "DM4310", 0x05, 0x15), + DMMotorSpecConfig("joint6", "DM4310", 0x06, 0x16), + DMMotorSpecConfig("joint7", "DM4310", 0x07, 0x17), +) + +_DEFAULT_POSITION_LOWER = [-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50] +_DEFAULT_POSITION_UPPER = [1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50] +_DEFAULT_VELOCITY_MAX = [45.0, 45.0, 8.0, 8.0, 30.0, 30.0, 30.0] +# _DEFAULT_KP = [70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0] +_DEFAULT_KP = [0.0] * 7 +_DEFAULT_KD = [2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5] +_STATE_CACHE_TTL_S = 0.002 + + +def _load_dm_control() -> tuple[ModuleType, ModuleType]: + try: + dm_control = importlib.import_module("dm_control") + damiao = importlib.import_module("dm_control.damiao") + except ImportError as exc: + raise DMMotorBindingUnavailableError( + "The selected 'dm_motor_arm' adapter requires the Rust-backed dm_control " + "Python binding in the active environment. Install/provide that binding " + "before selecting adapter_type='dm_motor_arm'; DimOS will not install it " + "automatically." + ) from exc + return dm_control, damiao + + +def _coerce_motor_specs( + motor_specs: list[dict[str, Any] | DMMotorSpecConfig] | None, + dof: int, +) -> list[DMMotorSpecConfig]: + if motor_specs is None: + if dof != len(_DEFAULT_OPENARM_MOTORS): + raise ValueError( + "motor_specs is required when constructing a dm_motor_arm with " + f"{dof} DOF; the built-in default only describes OpenArm-style 7 DOF" + ) + return list(_DEFAULT_OPENARM_MOTORS) + specs: list[DMMotorSpecConfig] = [] + for spec in motor_specs: + if isinstance(spec, DMMotorSpecConfig): + specs.append(spec) + else: + specs.append(DMMotorSpecConfig(**spec)) + if len(specs) != dof: + raise ValueError(f"motor_specs length {len(specs)} does not match dof {dof}") + return specs + + +def _resolve_motor_type(damiao: ModuleType, motor_type: str | int) -> Any: + if isinstance(motor_type, str): + try: + return getattr(damiao.MotorType, motor_type) + except AttributeError as exc: + raise ValueError(f"Unknown Damiao motor type {motor_type!r}") from exc + + for name in dir(damiao.MotorType): + if name.startswith("_"): + continue + candidate = getattr(damiao.MotorType, name) + try: + candidate_value = int(candidate) + except (TypeError, ValueError): + continue + if candidate_value == motor_type: + return candidate + raise ValueError(f"Unknown Damiao motor type value {motor_type!r}") + + +class DMMotorArm: + def __init__( + self, + address: str | Path | None = "can0", + dof: int = 7, + *, + hardware_id: str = "arm", + config_path: str | Path | None = None, + arm_name: str = "arm", + bus_name: str = "can", + fd: bool | None = None, + canfd: bool = True, + use_mock_bus: bool = False, + motor_specs: list[dict[str, Any] | DMMotorSpecConfig] | None = None, + position_lower: list[float] | None = None, + position_upper: list[float] | None = None, + velocity_max: list[float] | None = None, + kp: list[float] | None = None, + kd: list[float] | None = None, + gravity_comp: bool = True, + tick_deadline_us: int = 1_000, + state_cache_ttl_s: float = _STATE_CACHE_TTL_S, + gravity_model_path: str | Path | None = None, + gravity_torque_limits: list[float] | None = None, + **_: Any, + ) -> None: + self._address = str(address) if address is not None else "can0" + self._dof = dof + self._hardware_id = hardware_id + self._config_path = str(config_path) if config_path is not None else None + self._arm_name = arm_name + self._bus_name = bus_name + self._fd = canfd if fd is None else fd + self._use_mock_bus = use_mock_bus + self._motor_specs = _coerce_motor_specs(motor_specs, dof) + self._position_lower = ( + list(position_lower) if position_lower is not None else _DEFAULT_POSITION_LOWER[:dof] + ) + self._position_upper = ( + list(position_upper) if position_upper is not None else _DEFAULT_POSITION_UPPER[:dof] + ) + self._velocity_max = ( + list(velocity_max) if velocity_max is not None else _DEFAULT_VELOCITY_MAX[:dof] + ) + self._kp = list(kp) if kp is not None else _DEFAULT_KP[:dof] + self._kd = list(kd) if kd is not None else _DEFAULT_KD[:dof] + self._gravity_comp = gravity_comp + self._tick_deadline_us = tick_deadline_us + self._state_cache_ttl_s = state_cache_ttl_s + self._gravity_model_path = ( + str(gravity_model_path) if gravity_model_path is not None else None + ) + self._gravity_torque_limits = ( + list(gravity_torque_limits) if gravity_torque_limits is not None else None + ) + + for name, values in { + "position_lower": self._position_lower, + "position_upper": self._position_upper, + "velocity_max": self._velocity_max, + "kp": self._kp, + "kd": self._kd, + }.items(): + if len(values) != dof: + raise ValueError(f"{name} length {len(values)} does not match dof {dof}") + + self._dm_control: ModuleType | None = None + self._damiao: ModuleType | None = None + self._robot: Any = None + self._arm: Any = None + self._connected = False + self._enabled = False + self._control_mode = ControlMode.POSITION + self._last_positions: list[float] | None = None + self._state_cache: tuple[list[float], list[float], list[float]] | None = None + self._state_cache_time = 0.0 + self._pin_model: Any = None + self._pin_data: Any = None + + def connect(self) -> bool: + try: + self._dm_control, self._damiao = _load_dm_control() + self._robot = self._build_robot() + self._robot.connect() + self._arm = self._robot[self._arm_name] + if len(self._arm) != self._dof: + raise RuntimeError( + f"dm_control arm group {self._arm_name!r} has {len(self._arm)} joints, " + f"expected {self._dof}" + ) + self._load_gravity_model() + self._connected = True + self.refresh_state(force=True) + except DMMotorBindingUnavailableError: + raise + except Exception as exc: + logger.error(f"DMMotorArm {self._hardware_id}@{self._address} connect failed: {exc}") + self._robot = None + self._arm = None + self._connected = False + return False + return True + + def _build_robot(self) -> Any: + assert self._dm_control is not None + assert self._damiao is not None + if self._config_path is not None: + return self._dm_control.Robot.from_config(self._config_path) + + transport = ( + self._dm_control.MockCanBus.new_fd(self._address) + if self._use_mock_bus and self._fd + else self._dm_control.MockCanBus(self._address) + if self._use_mock_bus + else self._dm_control.SocketCanBus(self._address, fd=self._fd) + ) + codec = self._damiao.DamiaoCodec() + binding_specs = [ + self._dm_control.MotorSpec( + spec.name, + _resolve_motor_type(self._damiao, spec.type), + spec.send_id, + spec.recv_id, + ) + for spec in self._motor_specs + ] + return ( + self._dm_control.Robot.builder() + .add_bus(self._bus_name, transport, codec) + .add_arm(self._arm_name, bus=self._bus_name, motors=binding_specs) + .build() + ) + + def disconnect(self) -> None: + if self._robot is not None: + try: + self._robot.disable() + except Exception as exc: + logger.warning(f"DMMotorArm {self._hardware_id} disable on disconnect failed: {exc}") + self._enabled = False + self._connected = False + self._robot = None + self._arm = None + self._state_cache = None + + def is_connected(self) -> bool: + return self._connected + + def get_info(self) -> ManipulatorInfo: + return ManipulatorInfo( + vendor="Damiao", + model="DMMotorArm", + dof=self._dof, + firmware_version=None, + serial_number=None, + ) + + def get_dof(self) -> int: + return self._dof + + def get_limits(self) -> JointLimits: + return JointLimits( + position_lower=list(self._position_lower), + position_upper=list(self._position_upper), + velocity_max=list(self._velocity_max), + ) + + def set_control_mode(self, mode: ControlMode) -> bool: + if mode not in ( + ControlMode.POSITION, + ControlMode.SERVO_POSITION, + ControlMode.TORQUE, + ): + return False + self._control_mode = mode + return True + + def get_control_mode(self) -> ControlMode: + return self._control_mode + + def refresh_state( + self, *, force: bool = False + ) -> tuple[list[float], list[float], list[float]]: + if self._robot is None or self._arm is None: + raise RuntimeError("DMMotorArm is not connected") + now = time.monotonic() + if ( + not force + and self._state_cache is not None + and now - self._state_cache_time <= self._state_cache_ttl_s + ): + return self._state_cache + self._robot.tick(self._tick_deadline_us) + state = ( + self._arm.positions().astype(float).tolist(), + self._arm.velocities().astype(float).tolist(), + self._arm.torques().astype(float).tolist(), + ) + if any(len(values) != self._dof for values in state): + raise RuntimeError("dm_control state length does not match configured DOF") + self._state_cache = state + self._state_cache_time = time.monotonic() + self._last_positions = list(state[0]) + return state + + def read_joint_positions(self) -> list[float]: + return list(self.refresh_state()[0]) + + def read_joint_velocities(self) -> list[float]: + return list(self.refresh_state()[1]) + + def read_joint_efforts(self) -> list[float]: + return list(self.refresh_state()[2]) + + def read_state(self) -> dict[str, int]: + return { + "state": 1 if self._enabled else 0, + "mode": list(ControlMode).index(self._control_mode), + } + + def read_error(self) -> tuple[int, str]: + if self._arm is None: + return 0, "" + faults = [] + for spec in self._motor_specs: + motor = self._arm[spec.name] + fault = getattr(motor, "fault", None) + if fault is not None: + faults.append(f"{spec.name}: {fault}") + if not faults: + return 0, "" + return 1, "; ".join(faults) + + def write_joint_positions(self, positions: list[float], velocity: float = 1.0) -> bool: + if self._arm is None or self._robot is None or not self._enabled: + return False + if len(positions) != self._dof: + return False + velocity = max(0.0, min(1.0, velocity)) + if self._gravity_comp: + try: + q_current = self.read_joint_positions() + tau = self.compute_gravity_torques(q_current) + except RuntimeError: + tau = [0.0] * self._dof + else: + tau = [0.0] * self._dof + # print(f"write_joint_positions: positions={positions}, velocity={velocity}, tau={tau}") + return self.write_mit_commands( + q=list(positions), + dq=[0.0] * self._dof, + kp=[kp * velocity for kp in self._kp], + kd=list(self._kd), + tau=tau, + ) + + def write_joint_velocities(self, velocities: list[float]) -> bool: + return False + + def write_joint_torques(self, efforts: list[float]) -> bool: + if self._arm is None or self._robot is None or not self._enabled: + return False + if len(efforts) != self._dof: + return False + q = self._last_positions if self._last_positions is not None else self.read_joint_positions() + return self.write_mit_commands( + q=q, + dq=[0.0] * self._dof, + kp=[0.0] * self._dof, + kd=[0.0] * self._dof, + tau=efforts, + ) + + def write_gravity_compensation(self, damping: float | list[float] = 0.0) -> bool: + try: + q, dq, _ = self.refresh_state(force=True) + tau = self.compute_gravity_torques(q) + except Exception as exc: + logger.warning(f"Skipping DMMotor gravity compensation due to invalid state: {exc}") + return False + kd = ( + [float(damping)] * self._dof + if isinstance(damping, int | float) + else list(damping) + ) + if len(kd) != self._dof: + raise ValueError(f"damping length {len(kd)} does not match dof {self._dof}") + return self.write_mit_commands( + q=q, dq=dq, kp=[0.0] * self._dof, kd=kd, tau=tau + ) + + def write_mit_commands( + self, + *, + q: list[float], + dq: list[float], + kp: list[float], + kd: list[float], + tau: list[float], + ) -> bool: + if self._arm is None or self._robot is None or not self._enabled: + return False + for name, values in {"q": q, "dq": dq, "kp": kp, "kd": kd, "tau": tau}.items(): + if len(values) != self._dof: + raise ValueError(f"{name} length {len(values)} does not match dof {self._dof}") + cmds = np.array(list(zip(kp, kd, q, dq, tau, strict=False)), dtype=np.float64) + self._arm.mit_control(cmds) + self._robot.tick(self._tick_deadline_us) + self._state_cache = None + self._last_positions = list(q) + self._control_mode = ( + ControlMode.TORQUE if all(k == 0.0 for k in kp) else ControlMode.POSITION + ) + return True + + def write_stop(self) -> bool: + if self._arm is None or self._robot is None: + return False + if self._gravity_comp and self._enabled: + try: + q_now = self.read_joint_positions() + except RuntimeError: + return False + tau = self.compute_gravity_torques(q_now) + return self.write_mit_commands( + q=q_now, + dq=[0.0] * self._dof, + kp=list(self._kp), + kd=list(self._kd), + tau=tau, + ) + try: + self._robot.disable() + except Exception as exc: + logger.warning(f"DMMotorArm {self._hardware_id} stop disable failed: {exc}") + return False + self._enabled = False + return True + + def write_enable(self, enable: bool) -> bool: + if self._robot is None: + return False + try: + if enable: + self._robot.enable() + else: + self._robot.disable() + except Exception as exc: + logger.error(f"DMMotorArm {self._hardware_id} enable={enable} failed: {exc}") + return False + self._enabled = enable + return True + + def read_enabled(self) -> bool: + return self._enabled + + def write_clear_errors(self) -> bool: + if self._robot is None: + return False + try: + self._robot.disable() + self._robot.enable() + except Exception as exc: + logger.error(f"DMMotorArm {self._hardware_id} clear errors failed: {exc}") + return False + self._enabled = True + return True + + def read_cartesian_position(self) -> dict[str, float] | None: + return None + + def write_cartesian_position(self, pose: dict[str, float], velocity: float = 1.0) -> bool: + return False + + def read_gripper_position(self) -> float | None: + return None + + def write_gripper_position(self, position: float) -> bool: + return False + + def read_force_torque(self) -> list[float] | None: + return None + + def _load_gravity_model(self) -> None: + if not self._gravity_comp or self._gravity_model_path is None: + return + import pinocchio + + self._pin_model = pinocchio.buildModelFromUrdf(self._gravity_model_path) + self._pin_data = self._pin_model.createData() + + def compute_gravity_torques(self, q: list[float]) -> list[float]: + if len(q) != self._dof: + raise ValueError(f"q length {len(q)} does not match dof {self._dof}") + if self._pin_model is None or self._pin_data is None: + return [0.0] * self._dof + import pinocchio + + tau = pinocchio.computeGeneralizedGravity( + self._pin_model, + self._pin_data, + np.array(q, dtype=np.float64), + ) + values = [float(tau[i]) for i in range(self._dof)] + if self._gravity_torque_limits is None: + return values + if len(self._gravity_torque_limits) != self._dof: + raise ValueError("gravity_torque_limits length does not match dof") + return [ + float(np.clip(value, -limit, limit)) + for value, limit in zip(values, self._gravity_torque_limits, strict=False) + ] + + +def register(registry: AdapterRegistry) -> None: + registry.register("dm_motor_arm", DMMotorArm) + + +__all__ = ["DMMotorArm", "DMMotorBindingUnavailableError", "DMMotorSpecConfig", "register"] diff --git a/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py b/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py new file mode 100644 index 0000000000..875d981c27 --- /dev/null +++ b/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py @@ -0,0 +1,364 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from enum import IntEnum +import importlib +from types import ModuleType +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from dimos.hardware.manipulators.dm_motor_arm.adapter import ( + DMMotorArm, + DMMotorBindingUnavailableError, + register, +) +from dimos.hardware.manipulators.spec import ControlMode, ManipulatorAdapter + + +class FakeMotorType(IntEnum): + DM4310 = 1 + DM4340 = 3 + DM8006 = 6 + + +class FakeDamiao(ModuleType): + MotorType = FakeMotorType + + class DamiaoCodec: + pass + + +class FakeMotorSpec: + def __init__(self, name: str, type: FakeMotorType, send_id: int, recv_id: int) -> None: + if not isinstance(type, FakeMotorType): + raise TypeError("type must be FakeMotorType") + self.name = name + self.type = type + self.send_id = send_id + self.recv_id = recv_id + + +class FakeMotor: + fault: int | None = None + + +class FakeArm: + def __init__(self, dof: int) -> None: + self.dof = dof + self.positions_value = np.array([0.1 * i for i in range(dof)], dtype=np.float64) + self.velocities_value = np.array([0.2 * i for i in range(dof)], dtype=np.float64) + self.torques_value = np.array([0.3 * i for i in range(dof)], dtype=np.float64) + self.mit_commands: list[np.ndarray] = [] + self.position_commands: list[np.ndarray] = [] + self.velocity_commands: list[np.ndarray] = [] + self.motors = {f"joint{i + 1}": FakeMotor() for i in range(dof)} + + def __len__(self) -> int: + return self.dof + + def __getitem__(self, name: str) -> FakeMotor: + return self.motors[name] + + def positions(self) -> np.ndarray: + return self.positions_value + + def velocities(self) -> np.ndarray: + return self.velocities_value + + def torques(self) -> np.ndarray: + return self.torques_value + + def mit_control(self, cmds: np.ndarray) -> None: + self.mit_commands.append(cmds.copy()) + + def pos_vel_control(self, cmds: np.ndarray) -> None: + self.position_commands.append(cmds.copy()) + + def vel_control(self, cmds: np.ndarray) -> None: + self.velocity_commands.append(cmds.copy()) + + +class FakeRobot: + last: FakeRobot | None = None + + def __init__(self, dof: int = 7, transport: object | None = None) -> None: + FakeRobot.last = self + self.arm = FakeArm(dof) + self.transport = transport + self.connected = False + self.enabled = False + self.tick_count = 0 + self.disabled_count = 0 + + @classmethod + def builder(cls) -> FakeRobotBuilder: + return FakeRobotBuilder() + + @classmethod + def from_config(cls, path: str) -> FakeRobot: + robot = cls() + robot.config_path = path + return robot + + def connect(self) -> None: + self.connected = True + + def enable(self) -> None: + self.enabled = True + + def disable(self) -> None: + self.enabled = False + self.disabled_count += 1 + + def tick(self, per_bus_deadline_us: int) -> None: + self.tick_count += 1 + + def __getitem__(self, name: str) -> FakeArm: + return self.arm + + +class FakeRobotBuilder: + def __init__(self) -> None: + self.motors: list[FakeMotorSpec] = [] + self.transport: object | None = None + + def add_bus(self, name: str, transport: object, codec: object) -> FakeRobotBuilder: + self.transport = transport + return self + + def add_arm(self, name: str, *, bus: str, motors: list[FakeMotorSpec]) -> FakeRobotBuilder: + self.motors = motors + return self + + def build(self) -> FakeRobot: + return FakeRobot(len(self.motors), transport=self.transport) + + +class FakeDMControl(ModuleType): + Robot = FakeRobot + MotorSpec = FakeMotorSpec + + class MockCanBus: + def __init__(self, name: str, fd: bool = False) -> None: + self.name = name + self.fd = fd + + @staticmethod + def new_fd(name: str) -> FakeDMControl.MockCanBus: + return FakeDMControl.MockCanBus(name, fd=True) + + class SocketCanBus: + def __init__(self, interface: str, fd: bool = False) -> None: + self.interface = interface + self.fd = fd + + +@pytest.fixture(autouse=True) +def fake_dm_control(monkeypatch: pytest.MonkeyPatch) -> None: + fake_dm = FakeDMControl("dm_control") + fake_damiao = FakeDamiao("dm_control.damiao") + monkeypatch.setitem(__import__("sys").modules, "dm_control", fake_dm) + monkeypatch.setitem(__import__("sys").modules, "dm_control.damiao", fake_damiao) + FakeRobot.last = None + + +def test_implements_manipulator_adapter() -> None: + assert isinstance(DMMotorArm(use_mock_bus=True), ManipulatorAdapter) + + +def test_register() -> None: + registry = MagicMock() + register(registry) + registry.register.assert_called_once_with("dm_motor_arm", DMMotorArm) + + +def test_defaults_match_openarm_ros2_hardware_presets() -> None: + adapter = DMMotorArm(use_mock_bus=True) + assert adapter._kp == pytest.approx([70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0]) + assert adapter._kd == pytest.approx([2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5]) + + +def test_canfd_enabled_by_default_for_mock_and_socket_buses() -> None: + mock_adapter = DMMotorArm(use_mock_bus=True) + assert mock_adapter.connect() is True + mock_robot = FakeRobot.last + assert mock_robot is not None + assert mock_adapter._fd is True + assert mock_robot.transport is not None + assert getattr(mock_robot.transport, "fd") is True + + socket_adapter = DMMotorArm(use_mock_bus=False) + assert socket_adapter.connect() is True + socket_robot = FakeRobot.last + assert socket_robot is not None + assert socket_adapter._fd is True + assert socket_robot.transport is not None + assert getattr(socket_robot.transport, "fd") is True + + +def test_canfd_flag_and_legacy_fd_override_can_disable_fd() -> None: + canfd_adapter = DMMotorArm(use_mock_bus=True, canfd=False) + assert canfd_adapter.connect() is True + canfd_robot = FakeRobot.last + assert canfd_robot is not None + assert canfd_adapter._fd is False + assert canfd_robot.transport is not None + assert getattr(canfd_robot.transport, "fd") is False + + fd_adapter = DMMotorArm(use_mock_bus=True, canfd=True, fd=False) + assert fd_adapter.connect() is True + fd_robot = FakeRobot.last + assert fd_robot is not None + assert fd_adapter._fd is False + assert fd_robot.transport is not None + assert getattr(fd_robot.transport, "fd") is False + + +def test_motor_specs_use_binding_motor_type_values() -> None: + adapter = DMMotorArm( + use_mock_bus=True, + dof=2, + motor_specs=[ + {"name": "joint1", "type": "DM4310", "send_id": 1, "recv_id": 17}, + {"name": "joint2", "type": 6, "send_id": 2, "recv_id": 18}, + ], + ) + assert adapter.connect() is True + assert adapter._robot is not None + assert adapter._robot.arm.dof == 2 + + +def test_missing_binding_fails_only_when_selected(monkeypatch: pytest.MonkeyPatch) -> None: + real_import_module = importlib.import_module + + def fail_dm_control(name: str, package: str | None = None) -> ModuleType: + if name.startswith("dm_control"): + raise ImportError(name) + return real_import_module(name, package) + + monkeypatch.setattr(importlib, "import_module", fail_dm_control) + adapter = DMMotorArm(use_mock_bus=True) + with pytest.raises(DMMotorBindingUnavailableError, match="requires.*dm_control"): + adapter.connect() + + +def test_lifecycle_read_write_disable() -> None: + adapter = DMMotorArm(use_mock_bus=True, gravity_comp=False) + assert adapter.connect() is True + assert adapter.write_enable(True) is True + assert adapter.read_enabled() is True + assert adapter.read_joint_positions() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True + robot = FakeRobot.last + assert robot is not None + assert robot.arm.position_commands == [] + assert robot.arm.velocity_commands == [] + cmd = robot.arm.mit_commands[-1] + assert cmd[:, 2].tolist() == pytest.approx([0.1] * 7) + assert cmd[:, 0].tolist() == pytest.approx([35.0, 35.0, 35.0, 30.0, 5.0, 5.0, 5.0]) + assert cmd[:, 1].tolist() == pytest.approx([2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5]) + assert cmd[:, 4].tolist() == pytest.approx([0.0] * 7) + adapter.disconnect() + assert robot.disabled_count >= 1 + + +def test_state_reads_share_one_tick() -> None: + adapter = DMMotorArm(use_mock_bus=True, state_cache_ttl_s=10.0) + assert adapter.connect() is True + robot = FakeRobot.last + assert robot is not None + robot.tick_count = 0 + adapter._state_cache = None + adapter.read_joint_positions() + adapter.read_joint_velocities() + adapter.read_joint_efforts() + assert robot.tick_count == 1 + + +def test_default_gains_match_openarm_ros2_presets() -> None: + adapter = DMMotorArm(use_mock_bus=True) + assert adapter.connect() is True + assert adapter.write_enable(True) is True + assert adapter.write_joint_positions([0.1] * 7) is True + robot = FakeRobot.last + assert robot is not None + cmd = robot.arm.mit_commands[-1] + assert cmd[:, 0].tolist() == pytest.approx([70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0]) + assert cmd[:, 1].tolist() == pytest.approx([2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5]) + + +def test_position_commands_use_in_place_gravity_compensation_by_default() -> None: + adapter = DMMotorArm(use_mock_bus=True, kp=[10.0] * 7, kd=[0.2] * 7) + assert adapter.connect() is True + assert adapter.write_enable(True) is True + setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) + assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True + robot = FakeRobot.last + assert robot is not None + assert robot.arm.position_commands == [] + cmd = robot.arm.mit_commands[-1] + assert cmd[:, 2].tolist() == pytest.approx([0.1] * 7) + assert cmd[:, 3].tolist() == pytest.approx([0.0] * 7) + assert cmd[:, 0].tolist() == pytest.approx([5.0] * 7) + assert cmd[:, 1].tolist() == pytest.approx([0.2] * 7) + assert cmd[:, 4].tolist() == pytest.approx([1.0] * 7) + assert adapter.get_control_mode() == ControlMode.POSITION + + +def test_velocity_mode_and_commands_are_unsupported() -> None: + adapter = DMMotorArm(use_mock_bus=True) + assert adapter.connect() is True + assert adapter.write_enable(True) is True + assert adapter.set_control_mode(ControlMode.VELOCITY) is False + assert adapter.write_joint_velocities([0.2] * 7) is False + robot = FakeRobot.last + assert robot is not None + assert robot.arm.mit_commands == [] + assert robot.arm.velocity_commands == [] + assert adapter.get_control_mode() == ControlMode.POSITION + + +def test_gravity_comp_false_uses_mit_position_without_effort() -> None: + adapter = DMMotorArm(use_mock_bus=True, gravity_comp=False, kp=[10.0] * 7, kd=[0.2] * 7) + assert adapter.connect() is True + assert adapter.write_enable(True) is True + assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True + robot = FakeRobot.last + assert robot is not None + assert robot.arm.position_commands == [] + assert robot.arm.velocity_commands == [] + cmd = robot.arm.mit_commands[-1] + assert cmd[:, 2].tolist() == pytest.approx([0.1] * 7) + assert cmd[:, 0].tolist() == pytest.approx([5.0] * 7) + assert cmd[:, 1].tolist() == pytest.approx([0.2] * 7) + assert cmd[:, 4].tolist() == pytest.approx([0.0] * 7) + + +def test_gravity_compensation_command_shape() -> None: + adapter = DMMotorArm(use_mock_bus=True) + assert adapter.connect() is True + assert adapter.write_enable(True) is True + setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) + assert adapter.write_gravity_compensation(damping=0.05) is True + robot = FakeRobot.last + assert robot is not None + cmd = robot.arm.mit_commands[-1] + assert cmd[:, 0].tolist() == pytest.approx([0.0] * 7) + assert cmd[:, 1].tolist() == pytest.approx([0.05] * 7) + assert cmd[:, 4].tolist() == pytest.approx([1.0] * 7) + assert adapter.get_control_mode() == ControlMode.TORQUE diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index efd0f563ba..4bad6df5a4 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -21,6 +21,8 @@ "coordinator-cartesian-ik-mock": "dimos.control.blueprints.teleop:coordinator_cartesian_ik_mock", "coordinator-cartesian-ik-piper": "dimos.control.blueprints.teleop:coordinator_cartesian_ik_piper", "coordinator-combined-xarm6": "dimos.control.blueprints.teleop:coordinator_combined_xarm6", + "coordinator-dm-motor-openarm": "dimos.robot.manipulators.openarm.blueprints:coordinator_dm_motor_openarm", + "coordinator-dm-motor-openarm-hold-test": "dimos.robot.manipulators.openarm.blueprints:coordinator_dm_motor_openarm_hold_test", "coordinator-dual-mock": "dimos.control.blueprints.dual:coordinator_dual_mock", "coordinator-dual-xarm": "dimos.control.blueprints.dual:coordinator_dual_xarm", "coordinator-flowbase": "dimos.control.blueprints.mobile:coordinator_flowbase", diff --git a/dimos/robot/catalog/openarm.py b/dimos/robot/catalog/openarm.py index b6e1238cf2..a02cc6f65e 100644 --- a/dimos/robot/catalog/openarm.py +++ b/dimos/robot/catalog/openarm.py @@ -37,6 +37,9 @@ _OPENARM_LEFT_MODEL = _OPENARM_PKG / "urdf/robot/openarm_v10_left.urdf" _OPENARM_RIGHT_MODEL = _OPENARM_PKG / "urdf/robot/openarm_v10_right.urdf" +# Public per-side and single-arm URDF paths used by blueprints/adapters. +OPENARM_V10_LEFT_MODEL = _OPENARM_LEFT_MODEL +OPENARM_V10_RIGHT_MODEL = _OPENARM_RIGHT_MODEL # Pre-expanded single-arm URDF for Pinocchio FK (keyboard teleop, IK, etc.) OPENARM_V10_FK_MODEL = _OPENARM_PKG / "urdf/robot/openarm_v10_single.urdf" @@ -117,4 +120,10 @@ def openarm_single( return RobotConfig(**defaults) -__all__ = ["OPENARM_V10_FK_MODEL", "openarm_arm", "openarm_single"] +__all__ = [ + "OPENARM_V10_FK_MODEL", + "OPENARM_V10_LEFT_MODEL", + "OPENARM_V10_RIGHT_MODEL", + "openarm_arm", + "openarm_single", +] diff --git a/dimos/robot/manipulators/openarm/blueprints.py b/dimos/robot/manipulators/openarm/blueprints.py index 5b3094c0cc..4f30ecf1d6 100644 --- a/dimos/robot/manipulators/openarm/blueprints.py +++ b/dimos/robot/manipulators/openarm/blueprints.py @@ -16,7 +16,7 @@ from __future__ import annotations -from dimos.control.coordinator import ControlCoordinator +from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import autoconnect from dimos.core.transport import LCMTransport from dimos.manipulation.manipulation_module import ManipulationModule @@ -24,6 +24,7 @@ from dimos.msgs.sensor_msgs.JointState import JointState from dimos.robot.catalog.openarm import ( OPENARM_V10_FK_MODEL, + OPENARM_V10_RIGHT_MODEL, openarm_arm as _openarm, openarm_single as _openarm_single, ) @@ -59,6 +60,11 @@ AUTO_SET_MIT_MODE = True _ADAPTER_KWARGS = {"auto_set_mit_mode": AUTO_SET_MIT_MODE} +_DM_MOTOR_ADAPTER_KWARGS = { + "gravity_model_path": OPENARM_V10_RIGHT_MODEL, + "gravity_comp": True, + "canfd": True, +} _left_hw = _openarm( side="left", address=LEFT_CAN, @@ -71,6 +77,13 @@ adapter_type="openarm", adapter_kwargs=_ADAPTER_KWARGS, ) +_dm_motor_hw = _openarm( + side="right", + name="arm", + adapter_type="dm_motor_arm", + address=RIGHT_CAN, + adapter_kwargs=_DM_MOTOR_ADAPTER_KWARGS, +) coordinator_openarm_left = ControlCoordinator.blueprint( hardware=[_left_hw.to_hardware_component()], @@ -103,6 +116,35 @@ } ) +coordinator_dm_motor_openarm = ControlCoordinator.blueprint( + hardware=[_dm_motor_hw.to_hardware_component()], + tasks=[_dm_motor_hw.to_task_config(task_name="traj_dm_motor_openarm")], +).transports( + { + ("joint_state", JointState): LCMTransport("/coordinator/joint_state", JointState), + } +) + +# Hardware bring-up test: immediately writes current-position SERVO_POSITION +# commands so the dm_motor_arm adapter emits MIT hold frames with gravity +# feed-forward. Use cautiously; this is an active write-path test. +coordinator_dm_motor_openarm_hold_test = ControlCoordinator.blueprint( + hardware=[_dm_motor_hw.to_hardware_component()], + tasks=[ + TaskConfig( + name="hold_dm_motor_openarm", + type="current_position_hold", + joint_names=_dm_motor_hw.joint_names, + priority=5, + auto_start=True, + ), + ], +).transports( + { + ("joint_state", JointState): LCMTransport("/coordinator/joint_state", JointState), + } +) + # ── Planner + coordinator (mock): Drake plans, mock adapters execute ──── # Great for visualizing motions in Meshcat with no hardware. @@ -210,6 +252,7 @@ __all__ = [ "coordinator_openarm_bimanual", + "coordinator_dm_motor_openarm", "coordinator_openarm_left", "coordinator_openarm_mock", "coordinator_openarm_right", diff --git a/docs/capabilities/manipulation/openarm_integration.md b/docs/capabilities/manipulation/openarm_integration.md index 6869864f5a..4a516c472c 100644 --- a/docs/capabilities/manipulation/openarm_integration.md +++ b/docs/capabilities/manipulation/openarm_integration.md @@ -22,7 +22,16 @@ Every other arm in dimos wraps a vendor Python SDK: | Go2 / G1 | WebRTC | Unitree SDK | | Panda | FCI | `panda-py` | -**OpenArm ships no Python SDK.** The only interface is raw CAN frames on the wire, speaking the Damiao MIT-mode protocol. So dimos includes a from-scratch driver that encodes/decodes the protocol directly on a SocketCAN bus. The reference implementation is the Enactic C++ library at [enactic/openarm_can](https://github.com/enactic/openarm_can) — we port the frame layout from there. +**OpenArm historically shipped no stable Python SDK.** The default `openarm` adapter still uses dimos' in-tree raw-CAN driver and remains the existing production path. DimOS also provides an opt-in `dm_motor_arm` adapter for environments that already provide the Rust-backed `dm_control` Python binding; this change does not install that binding. + +## Adapter paths + +| Adapter | Hardware API | Dependency expectation | Typical use | +|---|---|---|---| +| `openarm` | In-tree SocketCAN Damiao driver | `python-can` plus Pinocchio for gravity feed-forward | Existing OpenArm coordinator, planner, and teleop blueprints. | +| `dm_motor_arm` | Rust-backed `dm_control` Python binding | Binding must already be importable in the active environment | DMMotor bring-up, binding-backed coordinator operation, and gravity-compensation-only validation. | + +Selecting `dm_motor_arm` is explicit through blueprint or hardware config. Registry discovery remains available without `dm_control`; selecting the adapter fails with a clear missing-binding error if the package is absent. ## Architecture @@ -113,10 +122,11 @@ The register is persistent across power cycles, so you only need this once per m | `coordinator-openarm-bimanual` | Both arms, real hardware, no planner. | | `openarm-planner-coordinator` | **Main usable blueprint** — Drake planner + both arms on real hardware. | | `keyboard-teleop-openarm-mock` / `keyboard-teleop-openarm` | Single-arm Cartesian IK + pygame keyboard, mock / real. | +| `coordinator-dm-motor-openarm` | Opt-in single-arm coordinator path using `adapter_type="dm_motor_arm"`; gravity feed-forward is enabled in the adapter by default. | **Safety before hot-plugging hardware:** hold the arms before starting. On connect, the adapter enables all motors and sends gravity-comp holds — the arms go slightly stiff but don't leap. Ctrl-C to cleanly disable and exit. -First-time recommendation: mock planner to verify everything wires up, then real single-arm, then bimanual. +First-time recommendation for the existing `openarm` adapter: mock planner to verify everything wires up, then real single-arm, then bimanual. ```bash # smoke test (no hardware) @@ -129,6 +139,23 @@ dimos run coordinator-openarm-left dimos run openarm-planner-coordinator ``` +For the `dm_motor_arm` binding path, stage validation before trajectory control: binding mock or vcan, one motor enable/read, one motor low-rate hold, full-arm state monitor, adapter gravity compensation, then trajectory-control validation. + +```bash +# requires dm_control binding in the active environment +sudo MODE=fd ./dimos/robot/manipulators/openarm/scripts/openarm_can_up.sh can0 + +# read/trajectory coordinator: writes only after a task receives a command +dimos run coordinator-dm-motor-openarm + +# active write-path test: immediately holds current q with MIT + gravity feed-forward +dimos run coordinator-dm-motor-openarm-hold-test +``` + +The hold-test blueprint auto-starts a current-position hold task. It reads the current joints and writes those same positions every coordinator tick so the `DMMotorArm` adapter emits MIT position frames with gravity feed-forward. Use it only when the arm is supported and you intentionally want an active hardware write-path test. + +The `DMMotorArm` adapter opens CAN-FD by default (`canfd=True`) and computes model gravity feed-forward in-place when `gravity_comp=True` (the OpenArm blueprint default). It intentionally supports position and effort semantics only: position commands are sent as MIT commands with preset `kp/kd` gains and optional gravity feed-forward, while effort/gravity-only commands use `kp=0` so the arm does not hold a target pose. Velocity commands are rejected because nonzero gains make MIT commands maintain the supplied `q`. Set `gravity_comp=False` in adapter kwargs to keep position MIT commands but omit model feed-forward torque. + Meshcat will appear at http://localhost:7000. ### 5. Drive the arms from the manipulation client @@ -222,25 +249,26 @@ If you don't know which Cartesian targets are reachable, check first with the wo Linux assigns `can0`/`can1` in USB-enumeration order, which isn't guaranteed stable across reboots or cable swaps. If the arms come up "swapped" (commanding `left_arm` moves the physical right arm), flip these two constants at the top of [blueprints.py](/dimos/robot/manipulators/openarm/blueprints.py): ```python -LEFT_CAN = "can0" -RIGHT_CAN = "can1" +LEFT_CAN = "can1" +RIGHT_CAN = "can0" ``` -No other code changes are needed. +No other code changes are needed. The `coordinator-dm-motor-openarm` blueprint currently targets `RIGHT_CAN` (`can0`) and passes `canfd=True`; use `sudo MODE=fd ... openarm_can_up.sh can0` before running it. ### Gain tuning (MIT kp/kd) -Defaults live in [adapter.py](/dimos/hardware/manipulators/openarm/adapter.py). Gains are per-joint because the shoulder motors (DM8006, 40 Nm) tolerate higher kp than the wrist motors (DM4310, 10 Nm): +Defaults live in the adapter implementations. The binding-backed `dm_motor_arm` path uses the upstream OpenArm ROS2 hardware presets from `openarm_hardware/openarm_simple_hardware.hpp`; the in-tree `openarm` adapter keeps its existing gains. DMMotor/OpenArm ROS2 presets are: ```python -_DEFAULT_KP = [100.0, 100.0, 80.0, 80.0, 60.0, 60.0, 60.0] -_DEFAULT_KD = [1.5, 1.5, 1.0, 1.0, 0.8, 0.8, 0.8] +_DEFAULT_KP = [70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0] +_DEFAULT_KD = [2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5] ``` Guidelines: - `kp ∈ [0, 500]` in MIT mode. Higher kp = stiffer position tracking; too high → oscillation. - `kd ∈ [0, 5]`. Higher kd = more damping, but values above ~2 on these gearboxes cause high-frequency buzz/grinding. -- Gravity compensation is on by default (`gravity_comp=True`) — the adapter uses Pinocchio to compute `G(q)` and adds it as feedforward torque. This removes the need for very high kp to fight gravity, so prefer low kp + gravity comp over high kp. +- Gravity compensation is on by default (`gravity_comp=True`) for OpenArm-style adapters. The adapter uses Pinocchio to compute `G(q)` and adds it as feedforward torque in the same command path. This removes the need for very high kp to fight gravity, so prefer upstream-tuned kp/kd + gravity comp over increasing kp. +- For `dm_motor_arm`, use position commands for trajectory/coordinator control and direct effort/gravity-only commands for torque bring-up. Velocity commands are intentionally unsupported. ### Physical joint limits @@ -346,7 +374,7 @@ Persistent across power cycles. ## Design decisions - **Driver separate from adapter.** `driver.py` has zero dimos deps → unit-testable with a virtual CAN bus, reusable outside dimos. -- **MIT mode for everything.** MIT can emulate position (high kp), velocity (kp=0, nonzero kd+dq), and torque (kp=kd=0, nonzero tau). One code path. +- **MIT mode for DMMotor position/effort.** The binding-backed `dm_motor_arm` path intentionally rejects velocity commands; nonzero MIT gains hold `q`, so velocity semantics are not exposed. Position uses preset `kp/kd`; effort/gravity-only commands use `kp=0`. - **Gravity compensation on by default.** Eliminates steady-state position error without needing high kp. Needs Pinocchio + the per-side URDFs. - **One adapter per CAN bus, keyed by `address`.** Matches the Piper adapter pattern. Bimanual = two adapters with different `address` values. - **Per-side URDFs for Drake planning.** Loading the full 14-DOF bimanual URDF twice (once per robot instance) creates phantom-arm collisions with the "other" arm frozen at zero. The per-side URDFs keep only one arm's links + the torso, avoiding the phantom collisions while matching the bimanual kinematics exactly. diff --git a/openspec/changes/add-dm-motor-arm-adapter/.openspec.yaml b/openspec/changes/add-dm-motor-arm-adapter/.openspec.yaml new file mode 100644 index 0000000000..c66f6f490c --- /dev/null +++ b/openspec/changes/add-dm-motor-arm-adapter/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-05 diff --git a/openspec/changes/add-dm-motor-arm-adapter/design.md b/openspec/changes/add-dm-motor-arm-adapter/design.md new file mode 100644 index 0000000000..cfab818ecf --- /dev/null +++ b/openspec/changes/add-dm-motor-arm-adapter/design.md @@ -0,0 +1,121 @@ +## Context + +DimOS currently supports manipulators through the `ManipulatorAdapter` protocol, the manipulator adapter registry, `HardwareComponent.adapter_type`, and the `ControlCoordinator` read/compute/write loop. OpenArm hardware already has an `openarm` adapter that owns a custom Python SocketCAN driver, sets MIT mode, computes Pinocchio-based gravity feed-forward, and preserves existing OpenArm blueprints. + +The new `dm_control` package in `/home/cc/codes/dm_control_rs` exposes a Python binding for DMMotor/Damiao hardware. Its Python API owns robot lifecycle and bus ticks through `Robot.connect()`, `Robot.enable()`, repeated `Robot.tick(...)`, group state reads, and arm commands. This change must use that Python binding from DimOS and must not add dependency installation or package-management changes yet. + +Gravity compensation should follow the existing OpenArm adapter pattern: it is computed in-place inside adapter command writes and controlled with an adapter flag. The gravity-compensation-only helper method must not act like a trajectory or stiff position-hold controller. + +## Goals / Non-Goals + +**Goals:** + +- Add a DMMotor manipulator adapter path named `DMMotorArm` that uses the `dm_control` Python binding. +- Register the adapter under a stable DimOS adapter key, expected to be `dm_motor_arm`. +- Preserve existing `openarm` adapter and blueprint behavior unless a later change explicitly migrates it. +- Provide adapter-level gravity compensation that sends model-based feed-forward torque and can be enabled or disabled with an adapter flag. +- Keep dependency installation out of scope; environments selecting the adapter must already provide the Python binding. +- Document and validate safe hardware bring-up, especially enable/disable, state freshness, and shutdown behavior. + +**Non-Goals:** + +- Do not call the Rust crates directly from DimOS. +- Do not replace the existing `openarm` adapter registration in this change. +- Do not add pip/uv/maturin dependency installation to DimOS packaging yet. +- Do not expose new skills or MCP tools. +- Do not introduce a separate gravity-compensation module or blueprint. + +## DimOS Architecture + +The adapter should live in the existing manipulator hardware layer: + +```text +ControlCoordinator + -> HardwareComponent(adapter_type="dm_motor_arm") + -> manipulator adapter registry + -> DMMotorArm + -> dm_control Python binding + -> SocketCAN / MockCanBus / vcan-backed DMMotor hardware +``` + +The `DMMotorArm` class should satisfy `ManipulatorAdapter` for coordinator-compatible use. It should be discovered through the existing manipulator registry hook and created from `HardwareComponent` fields such as `address`, `dof`, `hardware_id`, and `adapter_kwargs`. The adapter module should not import `dm_control` at module import time; it should import lazily when the adapter is constructed or connected so unrelated DimOS users do not lose registry discovery when the binding is absent. + +The coordinator path should use existing `joint_state` output and command routing. The adapter must reconcile DimOS' separate read/write calls with `dm_control`'s queued-command plus `Robot.tick(...)` model by owning tick/cache semantics internally. Reads should return coherent cached positions, velocities, and efforts for one cycle rather than independently ticking three times. Writes should queue commands and flush/update at a controlled point so the CAN bus is not double-loaded. + +Gravity compensation lives in `DMMotorArm` rather than a separate module. With `gravity_comp=True`, adapter position writes compute `tau_g(q)` from the current measured joint state and send MIT commands with gravity feed-forward. The adapter intentionally supports position and effort semantics only; velocity control is rejected because nonzero gains would hold the supplied `q` anchor. The adapter also exposes a gravity-compensation-only helper that sends MIT commands with `kp=0`, configurable low/no damping, and gravity torque for direct bring-up tests: + +```text +ControlCoordinator / caller + -> DMMotorArm adapter + -> read current q/dq through dm_control tick/cache + -> compute tau_g(q) + -> send MIT position command with adapter gains or effort/gravity-only kp=0 +``` + +This follows the existing OpenArm adapter style and avoids a duplicate lifecycle thread or standalone gravity-compensation blueprint. + +No new DimOS `Spec` Protocol is required for this change unless implementation needs RPC injection between modules. No skill/MCP exposure is planned. If new runnable blueprints are added, `dimos/robot/all_blueprints.py` must be regenerated with `pytest dimos/robot/test_all_blueprints_generation.py`. + +## Decisions + +- **Use the Python binding, not Rust crates directly.** This keeps the integration inside DimOS' Python module/adapter system and matches the user's requested surface. +- **Create a new `dm_motor_arm` adapter instead of replacing `openarm`.** The current OpenArm adapter includes hardware-specific limits, MIT-mode setup, thermal reporting, and gravity compensation behavior. Replacing it would be a silent compatibility and safety change. +- **Keep `dm_control` dependency installation out of this change.** The adapter should clearly fail when selected and the Python package is unavailable, but DimOS packaging should not be changed yet. +- **Use lazy binding import.** Registry discovery should remain healthy even when `dm_control` is not installed. +- **Treat `Robot.tick(...)` as adapter-owned.** The adapter should ensure one coherent state snapshot per cycle and avoid ticking once per position/velocity/effort read. +- **Provide gravity compensation in-place through the adapter.** This matches the existing OpenArm implementation and keeps lifecycle/tick ownership inside one hardware adapter. +- **Use model-based gravity compensation with zero position stiffness for effort/gravity-only commands.** Gravity-only helper calls should send feed-forward torque and optional damping, not position targets with nonzero stiffness. + +## Safety / Simulation / Replay + +Hardware assumptions: + +- DMMotor/Damiao hardware is connected through Linux SocketCAN with CAN-FD enabled by default, or a compatible mock/vcan backend supported by the Python binding. +- The Python binding is already installed in the active runtime environment. +- Joint ordering in DimOS configuration matches the arm group ordering in the binding/config. +- Gravity compensation model and hardware joint signs/offsets are valid before enabling gravity-only mode on real hardware. + +Safety constraints: + +- Disable motors on shutdown, interruption, and disconnect. +- Do not auto-install dependencies or silently select a different adapter. +- Do not use position-hold gains in gravity-compensation-only helper commands. +- Start hardware QA at low rates with one motor or mock/vcan before full-arm bring-up. +- Make binding-unavailable failures explicit before attempting hardware access. + +Simulation/replay: + +- Replay is out of scope. +- Mock/vcan validation through `dm_control.MockCanBus` or SocketCAN virtual interfaces should be supported for development. +- Existing `openarm` mock/planner blueprints remain available and unchanged. + +Manual QA surface: + +- Registry discovery includes `dm_motor_arm` when the adapter module is present. +- Missing binding produces a clear selected-adapter error. +- Mock/vcan adapter cycles read state and accepts commands without hardware. +- Adapter gravity compensation can be enabled/disabled through `gravity_comp`, computes feed-forward torque in command writes, and the gravity-only helper keeps joints manually movable with `kp=0`. + +## Risks / Trade-offs + +- **Binding availability and API stability:** `dm_control` is a new binding. Mitigation: avoid package install changes, document expected availability, and keep adapter usage explicit. +- **Package-name collision:** `dm_control` is also a known Python package name in other ecosystems. Mitigation: document the expected binding source and verify import behavior in QA. +- **Tick timing:** The binding flushes queued commands and receives feedback through `Robot.tick(...)`. Mitigation: centralize tick/cache behavior in the adapter and avoid multiple ticks per coordinator cycle. +- **Gravity compensation correctness:** Incorrect model, joint signs, or offsets can push hardware unexpectedly. Mitigation: require mock/vcan and low-rate bring-up, document validation, and avoid position stiffness in gravity-only helper mode. + +## Migration / Rollout + +1. Add the new adapter and in-place gravity-compensation behavior without changing existing `openarm` registrations. +2. Add opt-in DMMotor/OpenArm-style blueprints using `adapter_type="dm_motor_arm"`. +3. Regenerate blueprint registry if runnable blueprints are added. +4. Update OpenArm/manipulation docs to distinguish legacy `openarm` and new DMMotor binding paths. +5. Validate mock/vcan, then one-motor hardware, then full-arm adapter gravity compensation. +6. Only consider migrating existing OpenArm blueprints after hardware parity and safety behavior are proven. + +## Open Questions + +- Should `dm_motor_arm` expose only `DMMotorArm`, or should it also provide an alias such as `dmmotorarm` for CLI convenience? +- Should the adapter build robots from a binding TOML config path, from DimOS `RobotConfig` fields, or support both? +- What model source should gravity compensation use for non-OpenArm DMMotor arms? +- What default damping, if any, is safe for gravity-compensation-only mode while preserving free joint motion? +- Should torque routing be generalized through `ControlCoordinator` in a later change? diff --git a/openspec/changes/add-dm-motor-arm-adapter/docs.md b/openspec/changes/add-dm-motor-arm-adapter/docs.md new file mode 100644 index 0000000000..e91e532a9b --- /dev/null +++ b/openspec/changes/add-dm-motor-arm-adapter/docs.md @@ -0,0 +1,30 @@ +## User-Facing Docs + +- Update `docs/capabilities/manipulation/openarm_integration.md` to describe two OpenArm/DMMotor paths: + - existing `openarm` adapter with in-tree custom CAN driver, + - new `dm_motor_arm` adapter using the `dm_control` Python binding when that binding is already installed. +- Update the OpenArm quick-start tables after blueprint names are finalized, including the opt-in DMMotor coordinator and the distinction from existing OpenArm trajectory/planner blueprints. +- Document that this change does not install `dm_control`; users must provide the Python binding in the active environment before selecting the new adapter. +- Document adapter-level gravity-compensation behavior: it computes gravity feed-forward in-place when enabled and can be disabled with `gravity_comp=False`. +- Add hardware bring-up guidance for mock/vcan, one-motor validation, full-arm state monitor, gravity compensation, and safe shutdown. + +## Contributor Docs + +- Update contributor-facing manipulation hardware guidance if the adapter introduces a reusable pattern for lazy optional SDK imports or binding-backed adapters. +- If new blueprint registry entries are added, mention the required generation command in implementation notes or relevant development docs: `pytest dimos/robot/test_all_blueprints_generation.py`. +- No broader development-process documentation is expected unless dependency packaging is added in a later change. + +## Coding-Agent Docs + +- Update `docs/coding-agents/` only if there is an existing manipulation or hardware-adapter guide that should mention the `dm_control` binding path and gravity-compensation QA steps. +- No `AGENTS.md` update is required unless implementation reveals a new repo-wide convention. + +## Doc Validation + +- Run documentation link validation for changed docs if available in the project workflow. +- Run `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable Python blocks are added or changed. +- Run `pytest dimos/robot/test_all_blueprints_generation.py` if new runnable blueprints are added and `dimos/robot/all_blueprints.py` changes. + +## No Docs Needed + +Documentation is needed because this change affects real hardware bring-up, adapter selection, dependency expectations, and operator-visible gravity compensation behavior. diff --git a/openspec/changes/add-dm-motor-arm-adapter/proposal.md b/openspec/changes/add-dm-motor-arm-adapter/proposal.md new file mode 100644 index 0000000000..5c7bf0fe9d --- /dev/null +++ b/openspec/changes/add-dm-motor-arm-adapter/proposal.md @@ -0,0 +1,40 @@ +## Why + +DimOS already has OpenArm support, but the current adapter owns a custom Python CAN driver because there was no stable Python SDK surface for Damiao/OpenArm motors. The new `dm_control` Python binding provides a Rust-backed control library with a Python API for building robots, opening SocketCAN buses, ticking control loops, reading motor state, and commanding arm groups. Using the Python binding keeps DimOS integration in the existing Python manipulator adapter layer while avoiding a direct Rust integration in this change. + +This change also needs built-in gravity compensation for DMMotor-based arms. The existing OpenArm adapter uses model-based gravity compensation inside MIT commands; the new adapter path should follow that pattern in-place through an adapter flag, applying feed-forward gravity torque without introducing a separate gravity-compensation module. + +## What Changes + +- Add a new DMMotor manipulator adapter behavior that uses the `dm_control` Python binding API rather than calling Rust crates directly. +- Add support for configuring DMMotor arm hardware through the existing manipulator adapter registry and ControlCoordinator hardware configuration path. +- Add an adapter-level gravity-compensation operating path for DMMotor arms that sends gravity feed-forward torque while keeping the behavior opt-in/configurable through adapter kwargs. +- Preserve existing `openarm` adapter behavior and blueprints unless explicitly migrated later; this change does not silently replace the current OpenArm custom CAN adapter. +- Do not add dependency installation or packaging changes yet; the adapter may document or expect the Python binding to already be available in the runtime environment. +- Mark hardware safety behavior explicitly: the adapter must stop or disable safely on shutdown and must avoid unintended stiff position-hold behavior in gravity-compensation-only commands. + +## Affected DimOS Surfaces + +- Modules/streams: Manipulator adapter behavior behind `ManipulatorAdapter`; ControlCoordinator read/write behavior through existing `joint_state` and command routing surfaces. +- Blueprints/CLI: New DMMotor/OpenArm-style hardware blueprint entry point for coordinator use through `dimos run` once registered. +- Skills/MCP: No direct skill or MCP tool changes planned for this proposal. +- Hardware/simulation/replay: Real SocketCAN DMMotor/OpenArm hardware bring-up; mock/vcan validation through the `dm_control` Python binding where available; no replay changes planned. +- Docs/generated registries: Manipulation/OpenArm documentation, blueprint registry generation if new runnable blueprints are added, and adapter registry discovery behavior. + +## Capabilities + +### New Capabilities + +- `dm-motor-manipulator-adapters`: Covers DMMotor arm adapter behavior through the Python binding, including lifecycle, state reads, command writes, binding availability assumptions, and safe shutdown. +- `gravity-compensation-control`: Covers gravity-compensation-only behavior for manipulators, including operator-visible expectations that joints remain free to move while gravity torque is compensated. +- `manipulation-stack`: Covers manipulation-stack integration behavior for DMMotor hardware through DimOS blueprints and coordinator-compatible surfaces. + +### Modified Capabilities + +- None. + +## Impact + +Users gain a new path for DMMotor/OpenArm-style hardware that relies on the `dm_control` Python binding instead of maintaining another in-tree low-level CAN implementation. Developers can keep the integration inside the established Python adapter registry and ControlCoordinator flow, while future design work can decide when or whether existing OpenArm blueprints should migrate. + +Compatibility risk is primarily around hardware safety, binding availability, joint ordering, tick timing, and gravity compensation semantics. No dependency installation should be introduced in this change yet, so environments that select the new adapter must already provide the `dm_control` Python package. Documentation and QA must cover mock/vcan validation, one-motor bring-up, full-arm state monitoring, adapter gravity-compensation behavior, and shutdown/disable behavior on interruption. diff --git a/openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md new file mode 100644 index 0000000000..a7c8db28dd --- /dev/null +++ b/openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: DMMotor adapter selection + +DimOS SHALL provide an opt-in DMMotor manipulator adapter path for DMMotor/Damiao arms that uses the `dm_control` Python binding as its hardware API surface. + +#### Scenario: Selecting the DMMotor adapter +- **GIVEN** a manipulator hardware configuration selects the DMMotor adapter type +- **AND** the `dm_control` Python binding is available in the runtime environment +- **WHEN** the hardware is initialized +- **THEN** DimOS SHALL create the DMMotor adapter through the manipulator adapter registry +- **AND** the adapter SHALL use the Python binding rather than direct Rust crate calls. + +#### Scenario: Binding unavailable +- **GIVEN** a manipulator hardware configuration selects the DMMotor adapter type +- **AND** the `dm_control` Python binding is not importable +- **WHEN** the hardware is initialized +- **THEN** DimOS SHALL fail with an explicit error indicating that the Python binding is unavailable +- **AND** DimOS SHALL NOT attempt to install dependencies automatically. + +### Requirement: DMMotor adapter lifecycle safety + +The DMMotor adapter SHALL expose safe lifecycle behavior for connection, enablement, state reads, command writes, and shutdown. + +#### Scenario: Clean lifecycle +- **GIVEN** a configured DMMotor arm with available hardware or mock transport +- **WHEN** DimOS connects, enables, reads state, writes commands, and stops the adapter +- **THEN** the adapter SHALL perform those lifecycle steps through the Python binding +- **AND** the adapter SHALL disable the motors during stop or disconnect. + +#### Scenario: Interrupted operation +- **GIVEN** a DMMotor arm is enabled through DimOS +- **WHEN** the owning module or blueprint is stopped or interrupted +- **THEN** DimOS SHALL attempt to disable the motors before releasing the binding object +- **AND** shutdown failures SHALL be surfaced or logged without masking the need for safe operator intervention. + +### Requirement: DMMotor state and command compatibility + +The DMMotor adapter SHALL present manipulator state and position/effort command behavior compatible with DimOS manipulator control surfaces. + +#### Scenario: Reading coherent joint state +- **GIVEN** DimOS requests joint positions, velocities, and efforts for a DMMotor arm during one control cycle +- **WHEN** the adapter reads from the Python binding +- **THEN** the returned values SHALL represent one coherent state snapshot in DimOS joint order +- **AND** repeated per-field reads SHALL NOT independently advance the hardware loop multiple times for the same cycle. + +#### Scenario: Commanding joints +- **GIVEN** DimOS sends a supported position or effort command to a connected DMMotor arm +- **WHEN** the adapter forwards the command through the Python binding +- **THEN** the command SHALL be applied using SI units expected by DimOS +- **AND** the adapter SHALL preserve joint ordering between the DimOS hardware configuration and the binding arm group. + +#### Scenario: Rejecting velocity commands +- **GIVEN** DimOS sends a velocity command to a connected DMMotor arm +- **WHEN** the adapter evaluates the command +- **THEN** the adapter SHALL reject the command rather than emulate velocity with nonzero MIT position gains. diff --git a/openspec/changes/add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md b/openspec/changes/add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md new file mode 100644 index 0000000000..4f52860798 --- /dev/null +++ b/openspec/changes/add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Gravity-compensation-only operation + +DimOS SHALL provide adapter-level gravity-compensation behavior for supported manipulators that compensates gravity without requiring a separate gravity-compensation module. + +#### Scenario: Starting gravity compensation +- **GIVEN** a supported DMMotor arm is configured with `gravity_comp=True` +- **WHEN** the adapter receives a supported position, effort, or gravity-only command +- **THEN** DimOS SHALL apply model-based gravity feed-forward torque for the current joint configuration +- **AND** DimOS SHALL keep gravity compensation in the adapter command path rather than a standalone module. + +#### Scenario: Joints remain free to move +- **GIVEN** the adapter gravity-only helper is active +- **WHEN** a person or external force moves a joint within the safe operating range +- **THEN** the arm SHALL remain manually movable +- **AND** DimOS SHALL send zero position stiffness with configurable low/no damping and gravity feed-forward from the current measured joint state. + +### Requirement: Gravity-compensation safety + +Adapter gravity-compensation operation SHALL make hardware safety expectations explicit and fail safe on shutdown. + +#### Scenario: Stopping gravity compensation +- **GIVEN** adapter gravity compensation is enabled +- **WHEN** the adapter is stopped, disconnected, or disabled +- **THEN** DimOS SHALL disable or otherwise stop commanding the arm through the hardware binding +- **AND** the operator-visible result SHALL be a safe shutdown rather than a continued background control loop. + +#### Scenario: Invalid or stale state +- **GIVEN** gravity compensation requires current joint state +- **WHEN** DimOS cannot obtain valid or fresh joint state from the hardware binding +- **THEN** DimOS SHALL avoid sending a gravity-compensation command based on invalid state +- **AND** DimOS SHALL surface the condition as a fault, warning, or stopped state for operator action. + +### Requirement: Gravity-compensation configuration + +DimOS SHALL expose gravity compensation through adapter configuration rather than a separate runnable gravity-compensation blueprint. + +#### Scenario: Disabling adapter gravity compensation +- **GIVEN** a supported DMMotor arm is configured with `gravity_comp=False` +- **WHEN** the adapter receives position commands +- **THEN** DimOS SHALL send MIT position commands using configured `kp/kd` gains +- **AND** DimOS SHALL not add model gravity feed-forward torque to those commands. diff --git a/openspec/changes/add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md b/openspec/changes/add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md new file mode 100644 index 0000000000..393176fef1 --- /dev/null +++ b/openspec/changes/add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Non-breaking DMMotor integration + +DimOS SHALL add DMMotor adapter and gravity-compensation behavior without silently changing existing OpenArm adapter behavior. + +#### Scenario: Existing OpenArm blueprints remain stable +- **GIVEN** an existing OpenArm blueprint selects the current OpenArm adapter +- **WHEN** this change is present +- **THEN** the blueprint SHALL continue to use the existing OpenArm adapter unless explicitly changed +- **AND** users SHALL be able to opt into the DMMotor adapter through a distinct adapter or blueprint selection. + +### Requirement: Manipulation hardware bring-up path + +DimOS SHALL support a staged bring-up path for DMMotor manipulators before normal trajectory execution. + +#### Scenario: Staged validation before full arm use +- **GIVEN** a developer or operator is validating DMMotor hardware +- **WHEN** they follow the documented DimOS bring-up path +- **THEN** the path SHALL allow validation with mock or virtual CAN transport before real hardware +- **AND** the path SHALL separate state monitoring, gravity compensation, and trajectory execution into distinct operator-visible steps. + +### Requirement: Blueprint registry visibility + +DimOS SHALL make new DMMotor manipulation blueprints discoverable through the normal blueprint listing surface when runnable blueprints are added. + +#### Scenario: Listing new blueprints +- **GIVEN** DMMotor runnable blueprints have been added +- **WHEN** a user runs the DimOS blueprint listing command +- **THEN** the new DMMotor blueprints SHALL appear with names that distinguish gravity compensation from trajectory-control operation +- **AND** generated blueprint registry files SHALL be kept current. diff --git a/openspec/changes/add-dm-motor-arm-adapter/tasks.md b/openspec/changes/add-dm-motor-arm-adapter/tasks.md new file mode 100644 index 0000000000..dea5aab897 --- /dev/null +++ b/openspec/changes/add-dm-motor-arm-adapter/tasks.md @@ -0,0 +1,50 @@ +## 1. Adapter Implementation + +- [x] 1.1 Create `dimos/hardware/manipulators/dm_motor_arm/adapter.py` with `DMMotorArm` implementing `ManipulatorAdapter`, registered as `dm_motor_arm`. +- [x] 1.2 Add lazy `dm_control` Python binding import inside adapter construction/connect paths so registry discovery does not fail when the binding is absent. +- [x] 1.3 Implement DMMotor lifecycle methods for connect, disconnect, enable, disable, clear error, state reads, and supported position/effort command writes through the Python binding. +- [x] 1.4 Implement adapter-owned tick/cache behavior so one DimOS state-read cycle returns coherent position, velocity, and effort values without independently ticking per field. +- [x] 1.5 Implement binding-unavailable and lifecycle-error handling with explicit selected-adapter errors and no dependency installation attempts. +- [x] 1.6 Add configuration support for binding robot construction from an existing binding TOML path and/or DimOS adapter kwargs, preserving DimOS joint ordering and defaulting CAN-FD on through `canfd=True`. +- [x] 1.7 Preserve existing `openarm` adapter registration and behavior; do not migrate current OpenArm blueprints unless they explicitly opt into `dm_motor_arm`. + +## 2. Gravity Compensation + +- [x] 2.1 Add model-based gravity compensation support in the DMMotor adapter using the current measured joint state and configured robot model. +- [x] 2.2 Ensure gravity-compensation-only commands use zero position stiffness and configurable low/no damping so joints remain free to move. +- [x] 2.3 Add stale/invalid-state handling that avoids sending gravity compensation commands based on invalid state and surfaces an operator-visible warning or fault. +- [x] 2.4 Add shutdown handling for gravity compensation that disables or stops commanding the arm on stop, interruption, and disconnect. + +## 3. Blueprints and Registry + +- [x] 3.1 Add an opt-in DMMotor hardware blueprint using `adapter_type="dm_motor_arm"` without replacing existing OpenArm blueprints. +- [x] 3.2 Add adapter kwargs for enabling/disabling in-place DMMotor gravity compensation. +- [x] 3.3 Keep blueprint names clear enough for `dimos list` to distinguish the DMMotor coordinator from existing OpenArm coordinator operation. +- [x] 3.4 Regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py` if new runnable blueprints are added. + +## 4. Tests + +- [x] 4.1 Add unit tests that adapter registry discovery includes `dm_motor_arm` without requiring top-level `dm_control` import success. +- [x] 4.2 Add missing-binding tests showing selected DMMotor adapter use fails with a clear message and does not auto-install dependencies. +- [x] 4.3 Add adapter lifecycle tests with a fake or mocked binding robot covering connect, enable, read, write, disable, and disconnect order. +- [x] 4.4 Add tick/cache tests proving one DimOS state-read cycle does not call the binding tick once per position, velocity, and effort read. +- [x] 4.5 Add command-shape/order tests for DMMotor position, effort, unsupported velocity, and MIT/gravity-compensation commands in DimOS joint order. +- [x] 4.6 Add gravity-compensation tests proving commands use feed-forward torque with zero position stiffness and avoid sending commands on stale state. + +## 5. Documentation + +- [x] 5.1 Update `docs/capabilities/manipulation/openarm_integration.md` to document the existing `openarm` adapter and new `dm_motor_arm` Python-binding path separately. +- [x] 5.2 Document that this change does not install `dm_control`; users must provide the expected Python binding in the active environment. +- [x] 5.3 Document adapter-level gravity compensation, upstream OpenArm ROS2 gain presets, and position/effort-only command semantics. +- [x] 5.4 Document staged bring-up: mock/vcan, one-motor validation, full-arm state monitor, gravity compensation, then trajectory-control validation. +- [x] 5.5 Update contributor or coding-agent docs only if implementation introduces a reusable lazy optional binding adapter pattern. + +## 6. Verification + +- [x] 6.1 Run `openspec validate add-dm-motor-arm-adapter`. +- [x] 6.2 Run focused manipulator adapter tests for `dm_motor_arm` and existing `openarm` coverage. +- [x] 6.3 Run focused coordinator tests that cover `ConnectedHardware` state reads and command writes if adapter/coordinator behavior changes. +- [x] 6.4 Run `pytest dimos/robot/test_all_blueprints_generation.py` if blueprint entries or generated registry output change. +- [x] 6.5 Run documentation validation for changed docs, including `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable blocks are added or changed. +- [x] 6.6 Run a mock or vcan DMMotor adapter smoke test through the library/blueprint surface before using real hardware. +- [ ] 6.7 Manually QA the staged hardware procedure on real DMMotor hardware only after mock/vcan validation: one motor enable/read, one motor low-rate hold, full-arm state monitor, adapter gravity compensation, then optional trajectory-control validation. From b2aa7dcd8f5e9dc2999f9574accc5c304fb0980d Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 5 Jun 2026 16:19:04 -0700 Subject: [PATCH 004/110] refactor: share damiao arm adapter base --- .../hardware/manipulators/damiao/__init__.py | 18 ++ .../manipulators/damiao/base_adapter.py | 190 ++++++++++++ dimos/hardware/manipulators/damiao/specs.py | 161 ++++++++++ .../manipulators/damiao/test_base_adapter.py | 110 +++++++ .../manipulators/dm_motor_arm/adapter.py | 282 ++++++------------ .../manipulators/dm_motor_arm/test_adapter.py | 53 +++- .../hardware/manipulators/openarm/adapter.py | 208 +++++-------- .../manipulators/openarm/test_adapter.py | 157 ++++++++++ .../manipulation/openarm_integration.md | 4 +- .../.openspec.yaml | 2 + .../refactor-damiao-arm-adapters/design.md | 126 ++++++++ .../refactor-damiao-arm-adapters/docs.md | 25 ++ .../refactor-damiao-arm-adapters/proposal.md | 39 +++ .../specs/damiao-arm-adapter-refactor/spec.md | 43 +++ .../openarm-adapter-compatibility/spec.md | 43 +++ .../refactor-damiao-arm-adapters/tasks.md | 43 +++ 16 files changed, 1156 insertions(+), 348 deletions(-) create mode 100644 dimos/hardware/manipulators/damiao/__init__.py create mode 100644 dimos/hardware/manipulators/damiao/base_adapter.py create mode 100644 dimos/hardware/manipulators/damiao/specs.py create mode 100644 dimos/hardware/manipulators/damiao/test_base_adapter.py create mode 100644 dimos/hardware/manipulators/openarm/test_adapter.py create mode 100644 openspec/changes/refactor-damiao-arm-adapters/.openspec.yaml create mode 100644 openspec/changes/refactor-damiao-arm-adapters/design.md create mode 100644 openspec/changes/refactor-damiao-arm-adapters/docs.md create mode 100644 openspec/changes/refactor-damiao-arm-adapters/proposal.md create mode 100644 openspec/changes/refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md create mode 100644 openspec/changes/refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md create mode 100644 openspec/changes/refactor-damiao-arm-adapters/tasks.md diff --git a/dimos/hardware/manipulators/damiao/__init__.py b/dimos/hardware/manipulators/damiao/__init__.py new file mode 100644 index 0000000000..6531e20677 --- /dev/null +++ b/dimos/hardware/manipulators/damiao/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase +from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec + +__all__ = ["DamiaoArmAdapterBase", "DamiaoArmSpec", "DamiaoMotorSpec"] diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py new file mode 100644 index 0000000000..ae73e6b184 --- /dev/null +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -0,0 +1,190 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import importlib +from pathlib import Path +from typing import Any, Protocol, cast + +import numpy as np + +from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec +from dimos.hardware.manipulators.spec import ControlMode, JointLimits, ManipulatorInfo + + +class _PinocchioModule(Protocol): + def buildModelFromUrdf(self, filename: str) -> Any: ... + + def computeGeneralizedGravity(self, model: Any, data: Any, q: np.ndarray) -> Any: ... + + +class DamiaoArmAdapterBase: + """Shared DimOS adapter behavior for Damiao-based manipulators.""" + + _supported_control_modes: tuple[ControlMode, ...] = ( + ControlMode.POSITION, + ControlMode.SERVO_POSITION, + ControlMode.TORQUE, + ) + + def __init__( + self, + *, + arm_spec: DamiaoArmSpec, + dof: int | None = None, + hardware_id: str = "arm", + kp: list[float] | None = None, + kd: list[float] | None = None, + gravity_comp: bool = True, + gravity_model_path: str | Path | None = None, + gravity_torque_limits: list[float] | tuple[float, ...] | None = None, + supported_control_modes: tuple[ControlMode, ...] | None = None, + ) -> None: + arm_spec.validate() + if dof is not None and dof != arm_spec.dof: + raise ValueError(f"{type(self).__name__} only supports {arm_spec.dof} DOF (got {dof})") + self._arm_spec = arm_spec + self._hardware_id = hardware_id + self._dof = arm_spec.dof + self._motor_specs = list(arm_spec.motors) + self._position_lower = list(arm_spec.position_lower) + self._position_upper = list(arm_spec.position_upper) + self._velocity_max = list(arm_spec.velocity_max) + self._kp = list(kp) if kp is not None else list(arm_spec.kp) + self._kd = list(kd) if kd is not None else list(arm_spec.kd) + self._validate_length("kp", self._kp) + self._validate_length("kd", self._kd) + self._gravity_comp = gravity_comp + resolved_gravity_model = gravity_model_path if gravity_model_path is not None else arm_spec.gravity_model_path + self._gravity_model_path = str(resolved_gravity_model) if resolved_gravity_model is not None else None + resolved_torque_limits = ( + gravity_torque_limits + if gravity_torque_limits is not None + else arm_spec.gravity_torque_limits + ) + self._gravity_torque_limits = ( + list(resolved_torque_limits) if resolved_torque_limits is not None else None + ) + if self._gravity_torque_limits is not None: + self._validate_length("gravity_torque_limits", self._gravity_torque_limits) + self._supported_control_modes = ( + supported_control_modes + if supported_control_modes is not None + else type(self)._supported_control_modes + ) + self._control_mode = ControlMode.POSITION + self._enabled = False + self._last_positions: list[float] | None = None + self._pin_model: Any = None + self._pin_data: Any = None + + def _validate_length(self, name: str, values: list[float]) -> None: + if len(values) != self._dof: + raise ValueError(f"{name} length {len(values)} does not match dof {self._dof}") + + def _validate_command_lengths(self, **commands: list[float]) -> None: + for name, values in commands.items(): + self._validate_length(name, values) + + def _zero_vector(self) -> list[float]: + return [0.0] * self._dof + + def _mit_command_rows( + self, + *, + q: list[float], + dq: list[float], + kp: list[float], + kd: list[float], + tau: list[float], + ) -> list[tuple[float, float, float, float, float]]: + self._validate_command_lengths(q=q, dq=dq, kp=kp, kd=kd, tau=tau) + return list(zip(q, dq, kp, kd, tau, strict=True)) + + def get_info(self) -> ManipulatorInfo: + return ManipulatorInfo( + vendor=self._arm_spec.vendor, + model=self._arm_spec.model, + dof=self._dof, + firmware_version=None, + serial_number=None, + ) + + def get_dof(self) -> int: + return self._dof + + def get_limits(self) -> JointLimits: + return JointLimits( + position_lower=list(self._position_lower), + position_upper=list(self._position_upper), + velocity_max=list(self._velocity_max), + ) + + def set_control_mode(self, mode: ControlMode) -> bool: + if mode not in self._supported_control_modes: + return False + self._control_mode = mode + return True + + def get_control_mode(self) -> ControlMode: + return self._control_mode + + def read_enabled(self) -> bool: + return self._enabled + + def read_cartesian_position(self) -> dict[str, float] | None: + return None + + def write_cartesian_position(self, pose: dict[str, float], velocity: float = 1.0) -> bool: + return False + + def read_gripper_position(self) -> float | None: + return None + + def write_gripper_position(self, position: float) -> bool: + return False + + def read_force_torque(self) -> list[float] | None: + return None + + def _load_gravity_model(self) -> None: + if not self._gravity_comp or self._gravity_model_path is None: + return + pinocchio = cast("_PinocchioModule", cast("object", importlib.import_module("pinocchio"))) + + self._pin_model = pinocchio.buildModelFromUrdf(self._gravity_model_path) + self._pin_data = self._pin_model.createData() + + def compute_gravity_torques(self, q: list[float]) -> list[float]: + self._validate_length("q", q) + if self._pin_model is None or self._pin_data is None: + return [0.0] * self._dof + pinocchio = cast("_PinocchioModule", cast("object", importlib.import_module("pinocchio"))) + + tau = pinocchio.computeGeneralizedGravity( + self._pin_model, + self._pin_data, + np.array(q, dtype=np.float64), + ) + values = [float(tau[i]) for i in range(self._dof)] + if self._gravity_torque_limits is None: + return values + return [ + float(np.clip(value, -limit, limit)) + for value, limit in zip(values, self._gravity_torque_limits, strict=False) + ] + + +__all__ = ["DamiaoArmAdapterBase"] diff --git a/dimos/hardware/manipulators/damiao/specs.py b/dimos/hardware/manipulators/damiao/specs.py new file mode 100644 index 0000000000..88316ad24b --- /dev/null +++ b/dimos/hardware/manipulators/damiao/specs.py @@ -0,0 +1,161 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class DamiaoMotorSpec: + """Typed metadata for one Damiao motor in adapter joint order.""" + + name: str + type: object + send_id: int + recv_id: int | None = None + + @property + def effective_recv_id(self) -> int: + return self.recv_id if self.recv_id is not None else (self.send_id | 0x10) + + +@dataclass(frozen=True) +class DamiaoArmSpec: + """Typed metadata for a Damiao-based arm adapter.""" + + name: str + vendor: str + model: str + motors: tuple[DamiaoMotorSpec, ...] + position_lower: tuple[float, ...] + position_upper: tuple[float, ...] + velocity_max: tuple[float, ...] + kp: tuple[float, ...] + kd: tuple[float, ...] + gravity_model_path: str | Path | None = None + gravity_torque_limits: tuple[float, ...] | None = None + requires_binding: bool = False + bus_name: str = "can" + arm_name: str = "arm" + fd: bool = False + supports_velocity: bool = False + + @property + def dof(self) -> int: + return len(self.motors) + @property + def joint_names(self) -> tuple[str, ...]: + return tuple(motor.name for motor in self.motors) + + @classmethod + def from_values( + cls, + *, + name: str, + vendor: str, + model: str, + motors: Sequence[Mapping[str, object] | DamiaoMotorSpec], + position_lower: list[float] | tuple[float, ...], + position_upper: list[float] | tuple[float, ...], + velocity_max: list[float] | tuple[float, ...], + kp: list[float] | tuple[float, ...], + kd: list[float] | tuple[float, ...], + gravity_model_path: str | Path | None = None, + gravity_torque_limits: list[float] | tuple[float, ...] | None = None, + requires_binding: bool = False, + bus_name: str = "can", + arm_name: str = "arm", + fd: bool = False, + supports_velocity: bool = False, + ) -> DamiaoArmSpec: + return cls( + name=name, + vendor=vendor, + model=model, + motors=coerce_motor_specs(motors, len(motors)), + position_lower=tuple(float(value) for value in position_lower), + position_upper=tuple(float(value) for value in position_upper), + velocity_max=tuple(float(value) for value in velocity_max), + kp=tuple(float(value) for value in kp), + kd=tuple(float(value) for value in kd), + gravity_model_path=gravity_model_path, + gravity_torque_limits=( + tuple(float(value) for value in gravity_torque_limits) + if gravity_torque_limits is not None + else None + ), + requires_binding=requires_binding, + bus_name=bus_name, + arm_name=arm_name, + fd=fd, + supports_velocity=supports_velocity, + ) + + def validate(self) -> None: + if not self.motors: + raise ValueError("DamiaoArmSpec requires at least one motor") + send_ids = [motor.send_id for motor in self.motors] + if len(set(send_ids)) != len(send_ids): + raise ValueError(f"duplicate send_id in {send_ids}") + recv_ids = [motor.effective_recv_id for motor in self.motors] + if len(set(recv_ids)) != len(recv_ids): + raise ValueError(f"duplicate recv_id in {recv_ids}") + for name, values in { + "position_lower": self.position_lower, + "position_upper": self.position_upper, + "velocity_max": self.velocity_max, + "kp": self.kp, + "kd": self.kd, + }.items(): + if len(values) != self.dof: + raise ValueError(f"{name} length {len(values)} does not match dof {self.dof}") + if self.gravity_torque_limits is not None and len(self.gravity_torque_limits) != self.dof: + raise ValueError("gravity_torque_limits length does not match dof") + + +def coerce_motor_specs( + motor_specs: Sequence[Mapping[str, object] | DamiaoMotorSpec], + dof: int, +) -> tuple[DamiaoMotorSpec, ...]: + specs: list[DamiaoMotorSpec] = [] + for spec in motor_specs: + if isinstance(spec, DamiaoMotorSpec): + specs.append(spec) + else: + name = spec.get("name") + send_id = spec.get("send_id") + recv_id = spec.get("recv_id") + if not isinstance(name, str): + raise TypeError("motor spec name must be a string") + if not isinstance(send_id, int): + raise TypeError("motor spec send_id must be an integer") + if recv_id is not None and not isinstance(recv_id, int): + raise TypeError("motor spec recv_id must be an integer") + specs.append( + DamiaoMotorSpec( + name=name, + type=spec.get("type"), + send_id=send_id, + recv_id=recv_id, + ) + ) + if len(specs) != dof: + raise ValueError(f"motor_specs length {len(specs)} does not match dof {dof}") + return tuple(specs) + + +__all__ = ["DamiaoArmSpec", "DamiaoMotorSpec", "coerce_motor_specs"] diff --git a/dimos/hardware/manipulators/damiao/test_base_adapter.py b/dimos/hardware/manipulators/damiao/test_base_adapter.py new file mode 100644 index 0000000000..82357f94c4 --- /dev/null +++ b/dimos/hardware/manipulators/damiao/test_base_adapter.py @@ -0,0 +1,110 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import pytest + +from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase +from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec +from dimos.hardware.manipulators.spec import ControlMode + + +def _arm_spec() -> DamiaoArmSpec: + return DamiaoArmSpec( + name="test_damiao", + vendor="Damiao", + model="TestArm", + motors=( + DamiaoMotorSpec("j1", "DM4310", 0x01, 0x11), + DamiaoMotorSpec("j2", "DM4310", 0x02, 0x12), + ), + position_lower=(-1.0, -2.0), + position_upper=(1.0, 2.0), + velocity_max=(3.0, 4.0), + kp=(5.0, 6.0), + kd=(0.1, 0.2), + gravity_torque_limits=(7.0, 8.0), + ) + + +def test_arm_spec_preserves_joint_order_and_metadata() -> None: + spec = _arm_spec() + assert spec.dof == 2 + assert spec.joint_names == ("j1", "j2") + assert [motor.send_id for motor in spec.motors] == [0x01, 0x02] + assert [motor.effective_recv_id for motor in spec.motors] == [0x11, 0x12] + + +def test_arm_spec_rejects_duplicate_ids() -> None: + with pytest.raises(ValueError, match="duplicate send_id"): + DamiaoArmSpec( + name="bad", + vendor="Damiao", + model="BadArm", + motors=( + DamiaoMotorSpec("j1", "DM4310", 0x01, 0x11), + DamiaoMotorSpec("j2", "DM4310", 0x01, 0x12), + ), + position_lower=(-1.0, -2.0), + position_upper=(1.0, 2.0), + velocity_max=(3.0, 4.0), + kp=(5.0, 6.0), + kd=(0.1, 0.2), + ).validate() + + +def test_arm_spec_rejects_length_mismatch() -> None: + with pytest.raises(ValueError, match="kp length 1 does not match dof 2"): + DamiaoArmSpec( + name="bad", + vendor="Damiao", + model="BadArm", + motors=( + DamiaoMotorSpec("j1", "DM4310", 0x01, 0x11), + DamiaoMotorSpec("j2", "DM4310", 0x02, 0x12), + ), + position_lower=(-1.0, -2.0), + position_upper=(1.0, 2.0), + velocity_max=(3.0, 4.0), + kp=(5.0,), + kd=(0.1, 0.2), + ).validate() + + +def test_base_adapter_info_limits_and_modes() -> None: + adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec()) + info = adapter.get_info() + assert info.vendor == "Damiao" + assert info.model == "TestArm" + assert adapter.get_dof() == 2 + limits = adapter.get_limits() + assert limits.position_lower == [-1.0, -2.0] + assert limits.position_upper == [1.0, 2.0] + assert limits.velocity_max == [3.0, 4.0] + assert adapter.set_control_mode(ControlMode.TORQUE) is True + assert adapter.get_control_mode() == ControlMode.TORQUE + assert adapter.set_control_mode(ControlMode.VELOCITY) is False + + +def test_base_adapter_validates_command_lengths() -> None: + adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec()) + with pytest.raises(ValueError, match="q length 1 does not match dof 2"): + adapter._mit_command_rows( + q=[0.0], + dq=[0.0, 0.0], + kp=[0.0, 0.0], + kd=[0.0, 0.0], + tau=[0.0, 0.0], + ) diff --git a/dimos/hardware/manipulators/dm_motor_arm/adapter.py b/dimos/hardware/manipulators/dm_motor_arm/adapter.py index 7defe04fe4..88edb908df 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/adapter.py +++ b/dimos/hardware/manipulators/dm_motor_arm/adapter.py @@ -12,10 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. - from __future__ import annotations -from dataclasses import dataclass import importlib from pathlib import Path import time @@ -24,7 +22,9 @@ import numpy as np -from dimos.hardware.manipulators.spec import ControlMode, JointLimits, ManipulatorInfo +from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase +from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec +from dimos.hardware.manipulators.spec import ControlMode from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: @@ -32,38 +32,13 @@ logger = setup_logger() +DMMotorSpecConfig = DamiaoMotorSpec + class DMMotorBindingUnavailableError(RuntimeError): pass -@dataclass(frozen=True) -class DMMotorSpecConfig: - name: str - type: str | int - send_id: int - recv_id: int - - -_DEFAULT_OPENARM_MOTORS: tuple[DMMotorSpecConfig, ...] = ( - DMMotorSpecConfig("joint1", "DM8006", 0x01, 0x11), - DMMotorSpecConfig("joint2", "DM8006", 0x02, 0x12), - DMMotorSpecConfig("joint3", "DM4340", 0x03, 0x13), - DMMotorSpecConfig("joint4", "DM4340", 0x04, 0x14), - DMMotorSpecConfig("joint5", "DM4310", 0x05, 0x15), - DMMotorSpecConfig("joint6", "DM4310", 0x06, 0x16), - DMMotorSpecConfig("joint7", "DM4310", 0x07, 0x17), -) - -_DEFAULT_POSITION_LOWER = [-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50] -_DEFAULT_POSITION_UPPER = [1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50] -_DEFAULT_VELOCITY_MAX = [45.0, 45.0, 8.0, 8.0, 30.0, 30.0, 30.0] -# _DEFAULT_KP = [70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0] -_DEFAULT_KP = [0.0] * 7 -_DEFAULT_KD = [2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5] -_STATE_CACHE_TTL_S = 0.002 - - def _load_dm_control() -> tuple[ModuleType, ModuleType]: try: dm_control = importlib.import_module("dm_control") @@ -78,35 +53,14 @@ def _load_dm_control() -> tuple[ModuleType, ModuleType]: return dm_control, damiao -def _coerce_motor_specs( - motor_specs: list[dict[str, Any] | DMMotorSpecConfig] | None, - dof: int, -) -> list[DMMotorSpecConfig]: - if motor_specs is None: - if dof != len(_DEFAULT_OPENARM_MOTORS): - raise ValueError( - "motor_specs is required when constructing a dm_motor_arm with " - f"{dof} DOF; the built-in default only describes OpenArm-style 7 DOF" - ) - return list(_DEFAULT_OPENARM_MOTORS) - specs: list[DMMotorSpecConfig] = [] - for spec in motor_specs: - if isinstance(spec, DMMotorSpecConfig): - specs.append(spec) - else: - specs.append(DMMotorSpecConfig(**spec)) - if len(specs) != dof: - raise ValueError(f"motor_specs length {len(specs)} does not match dof {dof}") - return specs - - -def _resolve_motor_type(damiao: ModuleType, motor_type: str | int) -> Any: +def _resolve_motor_type(damiao: ModuleType, motor_type: str | int | Any) -> Any: if isinstance(motor_type, str): try: return getattr(damiao.MotorType, motor_type) except AttributeError as exc: raise ValueError(f"Unknown Damiao motor type {motor_type!r}") from exc - + if not isinstance(motor_type, int): + return motor_type for name in dir(damiao.MotorType): if name.startswith("_"): continue @@ -120,7 +74,22 @@ def _resolve_motor_type(damiao: ModuleType, motor_type: str | int) -> Any: raise ValueError(f"Unknown Damiao motor type value {motor_type!r}") -class DMMotorArm: +class DMMotorArm(DamiaoArmAdapterBase): + _DEFAULT_OPENARM_MOTORS: tuple[DamiaoMotorSpec, ...] = ( + DamiaoMotorSpec("joint1", "DM8006", 0x01, 0x11), + DamiaoMotorSpec("joint2", "DM8006", 0x02, 0x12), + DamiaoMotorSpec("joint3", "DM4340", 0x03, 0x13), + DamiaoMotorSpec("joint4", "DM4340", 0x04, 0x14), + DamiaoMotorSpec("joint5", "DM4310", 0x05, 0x15), + DamiaoMotorSpec("joint6", "DM4310", 0x06, 0x16), + DamiaoMotorSpec("joint7", "DM4310", 0x07, 0x17), + ) + _DEFAULT_POSITION_LOWER: tuple[float, ...] = (-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50) + _DEFAULT_POSITION_UPPER: tuple[float, ...] = (1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50) + _DEFAULT_VELOCITY_MAX: tuple[float, ...] = (45.0, 45.0, 8.0, 8.0, 30.0, 30.0, 30.0) + _DEFAULT_KP: tuple[float, ...] = (70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0) + _DEFAULT_KD: tuple[float, ...] = (2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5) + def __init__( self, address: str | Path | None = "can0", @@ -133,7 +102,7 @@ def __init__( fd: bool | None = None, canfd: bool = True, use_mock_bus: bool = False, - motor_specs: list[dict[str, Any] | DMMotorSpecConfig] | None = None, + motor_specs: list[dict[str, Any] | DamiaoMotorSpec] | None = None, position_lower: list[float] | None = None, position_upper: list[float] | None = None, velocity_max: list[float] | None = None, @@ -141,63 +110,77 @@ def __init__( kd: list[float] | None = None, gravity_comp: bool = True, tick_deadline_us: int = 1_000, - state_cache_ttl_s: float = _STATE_CACHE_TTL_S, + state_cache_ttl_s: float = 0.002, gravity_model_path: str | Path | None = None, gravity_torque_limits: list[float] | None = None, **_: Any, ) -> None: + specs = self._coerce_motor_specs(motor_specs, dof) + arm_spec = DamiaoArmSpec.from_values( + name="dm_motor_arm", + vendor="Damiao", + model="DMMotorArm", + motors=tuple(specs), + position_lower=position_lower if position_lower is not None else self._DEFAULT_POSITION_LOWER[:dof], + position_upper=position_upper if position_upper is not None else self._DEFAULT_POSITION_UPPER[:dof], + velocity_max=velocity_max if velocity_max is not None else self._DEFAULT_VELOCITY_MAX[:dof], + kp=kp if kp is not None else self._DEFAULT_KP[:dof], + kd=kd if kd is not None else self._DEFAULT_KD[:dof], + gravity_model_path=gravity_model_path, + gravity_torque_limits=gravity_torque_limits, + bus_name=bus_name, + arm_name=arm_name, + fd=canfd if fd is None else fd, + ) + super().__init__( + arm_spec=arm_spec, + hardware_id=hardware_id, + gravity_comp=gravity_comp, + supported_control_modes=( + ControlMode.POSITION, + ControlMode.SERVO_POSITION, + ControlMode.TORQUE, + ), + ) self._address = str(address) if address is not None else "can0" - self._dof = dof - self._hardware_id = hardware_id self._config_path = str(config_path) if config_path is not None else None self._arm_name = arm_name self._bus_name = bus_name self._fd = canfd if fd is None else fd self._use_mock_bus = use_mock_bus - self._motor_specs = _coerce_motor_specs(motor_specs, dof) - self._position_lower = ( - list(position_lower) if position_lower is not None else _DEFAULT_POSITION_LOWER[:dof] - ) - self._position_upper = ( - list(position_upper) if position_upper is not None else _DEFAULT_POSITION_UPPER[:dof] - ) - self._velocity_max = ( - list(velocity_max) if velocity_max is not None else _DEFAULT_VELOCITY_MAX[:dof] - ) - self._kp = list(kp) if kp is not None else _DEFAULT_KP[:dof] - self._kd = list(kd) if kd is not None else _DEFAULT_KD[:dof] - self._gravity_comp = gravity_comp self._tick_deadline_us = tick_deadline_us self._state_cache_ttl_s = state_cache_ttl_s - self._gravity_model_path = ( - str(gravity_model_path) if gravity_model_path is not None else None - ) - self._gravity_torque_limits = ( - list(gravity_torque_limits) if gravity_torque_limits is not None else None - ) - - for name, values in { - "position_lower": self._position_lower, - "position_upper": self._position_upper, - "velocity_max": self._velocity_max, - "kp": self._kp, - "kd": self._kd, - }.items(): - if len(values) != dof: - raise ValueError(f"{name} length {len(values)} does not match dof {dof}") self._dm_control: ModuleType | None = None self._damiao: ModuleType | None = None self._robot: Any = None self._arm: Any = None self._connected = False - self._enabled = False - self._control_mode = ControlMode.POSITION - self._last_positions: list[float] | None = None self._state_cache: tuple[list[float], list[float], list[float]] | None = None self._state_cache_time = 0.0 - self._pin_model: Any = None - self._pin_data: Any = None + + @classmethod + def _coerce_motor_specs( + cls, + motor_specs: list[dict[str, Any] | DamiaoMotorSpec] | None, + dof: int, + ) -> list[DamiaoMotorSpec]: + if motor_specs is None: + if dof != len(cls._DEFAULT_OPENARM_MOTORS): + raise ValueError( + "motor_specs is required when constructing a dm_motor_arm with " + f"{dof} DOF; the built-in default only describes OpenArm-style 7 DOF" + ) + return list(cls._DEFAULT_OPENARM_MOTORS) + specs: list[DamiaoMotorSpec] = [] + for spec in motor_specs: + if isinstance(spec, DamiaoMotorSpec): + specs.append(spec) + else: + specs.append(DamiaoMotorSpec(**spec)) + if len(specs) != dof: + raise ValueError(f"motor_specs length {len(specs)} does not match dof {dof}") + return specs def connect(self) -> bool: try: @@ -242,7 +225,7 @@ def _build_robot(self) -> Any: spec.name, _resolve_motor_type(self._damiao, spec.type), spec.send_id, - spec.recv_id, + spec.effective_recv_id, ) for spec in self._motor_specs ] @@ -268,38 +251,6 @@ def disconnect(self) -> None: def is_connected(self) -> bool: return self._connected - def get_info(self) -> ManipulatorInfo: - return ManipulatorInfo( - vendor="Damiao", - model="DMMotorArm", - dof=self._dof, - firmware_version=None, - serial_number=None, - ) - - def get_dof(self) -> int: - return self._dof - - def get_limits(self) -> JointLimits: - return JointLimits( - position_lower=list(self._position_lower), - position_upper=list(self._position_upper), - velocity_max=list(self._velocity_max), - ) - - def set_control_mode(self, mode: ControlMode) -> bool: - if mode not in ( - ControlMode.POSITION, - ControlMode.SERVO_POSITION, - ControlMode.TORQUE, - ): - return False - self._control_mode = mode - return True - - def get_control_mode(self) -> ControlMode: - return self._control_mode - def refresh_state( self, *, force: bool = False ) -> tuple[list[float], list[float], list[float]]: @@ -364,13 +315,12 @@ def write_joint_positions(self, positions: list[float], velocity: float = 1.0) - q_current = self.read_joint_positions() tau = self.compute_gravity_torques(q_current) except RuntimeError: - tau = [0.0] * self._dof + tau = self._zero_vector() else: - tau = [0.0] * self._dof - # print(f"write_joint_positions: positions={positions}, velocity={velocity}, tau={tau}") + tau = self._zero_vector() return self.write_mit_commands( q=list(positions), - dq=[0.0] * self._dof, + dq=self._zero_vector(), kp=[kp * velocity for kp in self._kp], kd=list(self._kd), tau=tau, @@ -387,9 +337,9 @@ def write_joint_torques(self, efforts: list[float]) -> bool: q = self._last_positions if self._last_positions is not None else self.read_joint_positions() return self.write_mit_commands( q=q, - dq=[0.0] * self._dof, - kp=[0.0] * self._dof, - kd=[0.0] * self._dof, + dq=self._zero_vector(), + kp=self._zero_vector(), + kd=self._zero_vector(), tau=efforts, ) @@ -400,15 +350,9 @@ def write_gravity_compensation(self, damping: float | list[float] = 0.0) -> bool except Exception as exc: logger.warning(f"Skipping DMMotor gravity compensation due to invalid state: {exc}") return False - kd = ( - [float(damping)] * self._dof - if isinstance(damping, int | float) - else list(damping) - ) - if len(kd) != self._dof: - raise ValueError(f"damping length {len(kd)} does not match dof {self._dof}") + kd = [float(damping)] * self._dof if isinstance(damping, int | float) else list(damping) return self.write_mit_commands( - q=q, dq=dq, kp=[0.0] * self._dof, kd=kd, tau=tau + q=q, dq=dq, kp=self._zero_vector(), kd=kd, tau=tau ) def write_mit_commands( @@ -422,10 +366,8 @@ def write_mit_commands( ) -> bool: if self._arm is None or self._robot is None or not self._enabled: return False - for name, values in {"q": q, "dq": dq, "kp": kp, "kd": kd, "tau": tau}.items(): - if len(values) != self._dof: - raise ValueError(f"{name} length {len(values)} does not match dof {self._dof}") - cmds = np.array(list(zip(kp, kd, q, dq, tau, strict=False)), dtype=np.float64) + rows = self._mit_command_rows(q=q, dq=dq, kp=kp, kd=kd, tau=tau) + cmds = np.array([(row[2], row[3], row[0], row[1], row[4]) for row in rows], dtype=np.float64) self._arm.mit_control(cmds) self._robot.tick(self._tick_deadline_us) self._state_cache = None @@ -446,7 +388,7 @@ def write_stop(self) -> bool: tau = self.compute_gravity_torques(q_now) return self.write_mit_commands( q=q_now, - dq=[0.0] * self._dof, + dq=self._zero_vector(), kp=list(self._kp), kd=list(self._kd), tau=tau, @@ -473,9 +415,6 @@ def write_enable(self, enable: bool) -> bool: self._enabled = enable return True - def read_enabled(self) -> bool: - return self._enabled - def write_clear_errors(self) -> bool: if self._robot is None: return False @@ -488,51 +427,6 @@ def write_clear_errors(self) -> bool: self._enabled = True return True - def read_cartesian_position(self) -> dict[str, float] | None: - return None - - def write_cartesian_position(self, pose: dict[str, float], velocity: float = 1.0) -> bool: - return False - - def read_gripper_position(self) -> float | None: - return None - - def write_gripper_position(self, position: float) -> bool: - return False - - def read_force_torque(self) -> list[float] | None: - return None - - def _load_gravity_model(self) -> None: - if not self._gravity_comp or self._gravity_model_path is None: - return - import pinocchio - - self._pin_model = pinocchio.buildModelFromUrdf(self._gravity_model_path) - self._pin_data = self._pin_model.createData() - - def compute_gravity_torques(self, q: list[float]) -> list[float]: - if len(q) != self._dof: - raise ValueError(f"q length {len(q)} does not match dof {self._dof}") - if self._pin_model is None or self._pin_data is None: - return [0.0] * self._dof - import pinocchio - - tau = pinocchio.computeGeneralizedGravity( - self._pin_model, - self._pin_data, - np.array(q, dtype=np.float64), - ) - values = [float(tau[i]) for i in range(self._dof)] - if self._gravity_torque_limits is None: - return values - if len(self._gravity_torque_limits) != self._dof: - raise ValueError("gravity_torque_limits length does not match dof") - return [ - float(np.clip(value, -limit, limit)) - for value, limit in zip(values, self._gravity_torque_limits, strict=False) - ] - def register(registry: AdapterRegistry) -> None: registry.register("dm_motor_arm", DMMotorArm) diff --git a/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py b/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py index 875d981c27..6ea15ff281 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py +++ b/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py @@ -100,6 +100,7 @@ def __init__(self, dof: int = 7, transport: object | None = None) -> None: FakeRobot.last = self self.arm = FakeArm(dof) self.transport = transport + self.config_path: str | None = None self.connected = False self.enabled = False self.tick_count = 0 @@ -199,16 +200,16 @@ def test_canfd_enabled_by_default_for_mock_and_socket_buses() -> None: mock_robot = FakeRobot.last assert mock_robot is not None assert mock_adapter._fd is True - assert mock_robot.transport is not None - assert getattr(mock_robot.transport, "fd") is True + assert isinstance(mock_robot.transport, FakeDMControl.MockCanBus) + assert mock_robot.transport.fd is True socket_adapter = DMMotorArm(use_mock_bus=False) assert socket_adapter.connect() is True socket_robot = FakeRobot.last assert socket_robot is not None assert socket_adapter._fd is True - assert socket_robot.transport is not None - assert getattr(socket_robot.transport, "fd") is True + assert isinstance(socket_robot.transport, FakeDMControl.SocketCanBus) + assert socket_robot.transport.fd is True def test_canfd_flag_and_legacy_fd_override_can_disable_fd() -> None: @@ -217,16 +218,16 @@ def test_canfd_flag_and_legacy_fd_override_can_disable_fd() -> None: canfd_robot = FakeRobot.last assert canfd_robot is not None assert canfd_adapter._fd is False - assert canfd_robot.transport is not None - assert getattr(canfd_robot.transport, "fd") is False + assert isinstance(canfd_robot.transport, FakeDMControl.MockCanBus) + assert canfd_robot.transport.fd is False fd_adapter = DMMotorArm(use_mock_bus=True, canfd=True, fd=False) assert fd_adapter.connect() is True fd_robot = FakeRobot.last assert fd_robot is not None assert fd_adapter._fd is False - assert fd_robot.transport is not None - assert getattr(fd_robot.transport, "fd") is False + assert isinstance(fd_robot.transport, FakeDMControl.MockCanBus) + assert fd_robot.transport.fd is False def test_motor_specs_use_binding_motor_type_values() -> None: @@ -302,11 +303,13 @@ def test_default_gains_match_openarm_ros2_presets() -> None: assert cmd[:, 1].tolist() == pytest.approx([2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5]) -def test_position_commands_use_in_place_gravity_compensation_by_default() -> None: +def test_position_commands_use_in_place_gravity_compensation_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: adapter = DMMotorArm(use_mock_bus=True, kp=[10.0] * 7, kd=[0.2] * 7) assert adapter.connect() is True assert adapter.write_enable(True) is True - setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) + monkeypatch.setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True robot = FakeRobot.last assert robot is not None @@ -349,11 +352,11 @@ def test_gravity_comp_false_uses_mit_position_without_effort() -> None: assert cmd[:, 4].tolist() == pytest.approx([0.0] * 7) -def test_gravity_compensation_command_shape() -> None: +def test_gravity_compensation_command_shape(monkeypatch: pytest.MonkeyPatch) -> None: adapter = DMMotorArm(use_mock_bus=True) assert adapter.connect() is True assert adapter.write_enable(True) is True - setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) + monkeypatch.setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) assert adapter.write_gravity_compensation(damping=0.05) is True robot = FakeRobot.last assert robot is not None @@ -362,3 +365,29 @@ def test_gravity_compensation_command_shape() -> None: assert cmd[:, 1].tolist() == pytest.approx([0.05] * 7) assert cmd[:, 4].tolist() == pytest.approx([1.0] * 7) assert adapter.get_control_mode() == ControlMode.TORQUE + + +def test_non_openarm_dof_requires_explicit_motor_specs() -> None: + with pytest.raises(ValueError, match="motor_specs is required"): + DMMotorArm(dof=2, use_mock_bus=True) + + +def test_custom_non_openarm_dof_uses_explicit_metadata() -> None: + adapter = DMMotorArm( + dof=2, + use_mock_bus=True, + motor_specs=[ + {"name": "shoulder", "type": "DM4310", "send_id": 1, "recv_id": 17}, + {"name": "elbow", "type": "DM4310", "send_id": 2, "recv_id": 18}, + ], + position_lower=[-0.5, -0.25], + position_upper=[0.5, 0.25], + velocity_max=[1.0, 2.0], + kp=[3.0, 4.0], + kd=[0.3, 0.4], + ) + assert adapter.get_dof() == 2 + assert [motor.name for motor in adapter._motor_specs] == ["shoulder", "elbow"] + assert adapter.get_limits().position_lower == [-0.5, -0.25] + assert adapter._kp == [3.0, 4.0] + assert adapter._kd == [0.3, 0.4] diff --git a/dimos/hardware/manipulators/openarm/adapter.py b/dimos/hardware/manipulators/openarm/adapter.py index 70554817e4..6dbe37775e 100644 --- a/dimos/hardware/manipulators/openarm/adapter.py +++ b/dimos/hardware/manipulators/openarm/adapter.py @@ -18,10 +18,10 @@ from pathlib import Path import time -from typing import TYPE_CHECKING, Any - -import numpy as np +from typing import TYPE_CHECKING, Any, cast +from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase +from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec from dimos.hardware.manipulators.openarm.driver import ( CTRL_MODE_MIT, DamiaoMotor, @@ -30,8 +30,6 @@ ) from dimos.hardware.manipulators.spec import ( ControlMode, - JointLimits, - ManipulatorInfo, ) from dimos.utils.data import LfsPath @@ -49,43 +47,26 @@ def _socketcan_iface_up(name: str) -> bool: return False -# OpenArm v10 BOM — (send_id, MotorType) per joint, derived from the torque -# column of data/openarm_description/config/arm/v10/joint_limits.yaml. -_OPENARM_V10_ARM_MOTORS: list[tuple[int, MotorType]] = [ - (0x01, MotorType.DM8006), # joint1 - (0x02, MotorType.DM8006), # joint2 - (0x03, MotorType.DM4340), # joint3 - (0x04, MotorType.DM4340), # joint4 - (0x05, MotorType.DM4310), # joint5 - (0x06, MotorType.DM4310), # joint6 - (0x07, MotorType.DM4310), # joint7 -] -# Gripper (motor id 0x08, DM4310) is on the bus but not currently wired up -# through the adapter — see the gripper-write methods which return None/False. - -# Physical joint limits (measured). Joints 1 & 2 are mirrored between sides. -_V10_POS_LOWER_LEFT = [-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50] -_V10_POS_UPPER_LEFT = [1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50] -_V10_POS_LOWER_RIGHT = [-1.35, -0.15, -1.50, -0.01, -1.50, -0.75, -1.50] -_V10_POS_UPPER_RIGHT = [3.45, 3.30, 1.50, 2.40, 1.50, 0.75, 1.50] -_V10_VEL_MAX = [16.754666, 16.754666, 5.445426, 5.445426, 20.943946, 20.943946, 20.943946] - -# Default MIT gains per joint for POSITION mode. -# kp range is [0, 500], kd range is [0, 5]. -# With gravity compensation enabled, the PD gains only handle transient -# tracking — they don't fight gravity. Lower kp = smoother, less buzz. -# High kd causes high-frequency buzz/grinding from the gearbox. -_DEFAULT_KP = [100.0, 100.0, 80.0, 80.0, 60.0, 60.0, 60.0] -_DEFAULT_KD = [1.5, 1.5, 1.0, 1.0, 0.8, 0.8, 0.8] -_STATE_MAX_AGE_S = 0.1 - - -class OpenArmAdapter: - """7-DOF OpenArm on one SocketCAN bus. side=left|right picks URDF + limits.""" - - # Per-side URDFs for Pinocchio gravity model (LFS-backed) +class OpenArmAdapter(DamiaoArmAdapterBase): _URDF_LEFT = LfsPath("openarm_description/urdf/robot/openarm_v10_left.urdf") _URDF_RIGHT = LfsPath("openarm_description/urdf/robot/openarm_v10_right.urdf") + _V10_ARM_MOTORS: tuple[DamiaoMotorSpec, ...] = ( + DamiaoMotorSpec("joint1", MotorType.DM8006, 0x01, 0x11), + DamiaoMotorSpec("joint2", MotorType.DM8006, 0x02, 0x12), + DamiaoMotorSpec("joint3", MotorType.DM4340, 0x03, 0x13), + DamiaoMotorSpec("joint4", MotorType.DM4340, 0x04, 0x14), + DamiaoMotorSpec("joint5", MotorType.DM4310, 0x05, 0x15), + DamiaoMotorSpec("joint6", MotorType.DM4310, 0x06, 0x16), + DamiaoMotorSpec("joint7", MotorType.DM4310, 0x07, 0x17), + ) + _V10_POS_LOWER_LEFT: tuple[float, ...] = (-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50) + _V10_POS_UPPER_LEFT: tuple[float, ...] = (1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50) + _V10_POS_LOWER_RIGHT: tuple[float, ...] = (-1.35, -0.15, -1.50, -0.01, -1.50, -0.75, -1.50) + _V10_POS_UPPER_RIGHT: tuple[float, ...] = (3.45, 3.30, 1.50, 2.40, 1.50, 0.75, 1.50) + _V10_VEL_MAX: tuple[float, ...] = (16.754666, 16.754666, 5.445426, 5.445426, 20.943946, 20.943946, 20.943946) + _DEFAULT_KP: tuple[float, ...] = (100.0, 100.0, 80.0, 80.0, 60.0, 60.0, 60.0) + _DEFAULT_KD: tuple[float, ...] = (1.5, 1.5, 1.0, 1.0, 0.8, 0.8, 0.8) + _STATE_MAX_AGE_S = 0.1 def __init__( self, @@ -101,33 +82,54 @@ def __init__( auto_set_mit_mode: bool = True, **_: Any, ) -> None: - if dof != 7: - raise ValueError(f"OpenArmAdapter only supports 7 DOF (got {dof})") if side not in ("left", "right"): raise ValueError(f"side must be 'left' or 'right', got {side!r}") + position_lower = self._V10_POS_LOWER_LEFT if side == "left" else self._V10_POS_LOWER_RIGHT + position_upper = self._V10_POS_UPPER_LEFT if side == "left" else self._V10_POS_UPPER_RIGHT + urdf = self._URDF_LEFT if side == "left" else self._URDF_RIGHT + arm_spec = DamiaoArmSpec( + name=f"openarm_{side}", + vendor="Enactic", + model=f"OpenArm v10 ({side})", + motors=self._V10_ARM_MOTORS, + position_lower=position_lower, + position_upper=position_upper, + velocity_max=self._V10_VEL_MAX, + kp=self._DEFAULT_KP, + kd=self._DEFAULT_KD, + gravity_model_path=urdf, + gravity_torque_limits=tuple( + DamiaoMotor(motor.send_id, cast("MotorType", motor.type), motor.recv_id).limits[2] + for motor in self._V10_ARM_MOTORS + ), + fd=fd, + supports_velocity=True, + ) + super().__init__( + arm_spec=arm_spec, + dof=dof, + kp=kp, + kd=kd, + gravity_comp=gravity_comp, + supported_control_modes=( + ControlMode.POSITION, + ControlMode.SERVO_POSITION, + ControlMode.VELOCITY, + ControlMode.TORQUE, + ), + ) self._address = address - self._dof = dof self._side = side self._fd = fd self._interface = interface - self._kp = list(kp) if kp is not None else list(_DEFAULT_KP) - self._kd = list(kd) if kd is not None else list(_DEFAULT_KD) - if len(self._kp) != dof or len(self._kd) != dof: - raise ValueError("kp/kd must be length 7") - self._gravity_comp = gravity_comp self._auto_set_mit_mode = auto_set_mit_mode - - self._motors = [DamiaoMotor(sid, mt) for sid, mt in _OPENARM_V10_ARM_MOTORS] + self._motors = [ + DamiaoMotor(spec.send_id, cast("MotorType", spec.type), spec.recv_id) + for spec in self._motor_specs + ] self._bus: OpenArmBus | None = None - self._control_mode: ControlMode = ControlMode.POSITION - self._enabled: bool = False - # Last successful position command — used as q_target for VELOCITY mode self._last_cmd_q: list[float] | None = None - # Pinocchio model for gravity compensation (loaded lazily in connect()) - self._pin_model: Any = None - self._pin_data: Any = None - def connect(self) -> bool: # Preflight: verify the SocketCAN interface is up before opening the bus. # Bringing the interface up requires root privileges, so we don't do it @@ -171,17 +173,13 @@ def connect(self) -> bool: "auto_set_mit_mode disabled — relying on persisted register" ) - # Load Pinocchio model for gravity compensation if self._gravity_comp: try: - import pinocchio - - urdf = str(self._URDF_LEFT if self._side == "left" else self._URDF_RIGHT) - self._pin_model = pinocchio.buildModelFromUrdf(urdf) - self._pin_data = self._pin_model.createData() - print( - f"OpenArm {self._side}: gravity compensation enabled (nq={self._pin_model.nq})" - ) + self._load_gravity_model() + if self._pin_model is not None: + print( + f"OpenArm {self._side}: gravity compensation enabled (nq={self._pin_model.nq})" + ) except Exception as e: print(f"WARNING: gravity comp disabled — {e}") self._pin_model = None @@ -203,46 +201,6 @@ def disconnect(self) -> None: def is_connected(self) -> bool: return self._bus is not None - def get_info(self) -> ManipulatorInfo: - return ManipulatorInfo( - vendor="Enactic", - model=f"OpenArm v10 ({self._side})", - dof=self._dof, - firmware_version=None, - serial_number=None, - ) - - def get_dof(self) -> int: - return self._dof - - def get_limits(self) -> JointLimits: - if self._side == "left": - lower, upper = _V10_POS_LOWER_LEFT, _V10_POS_UPPER_LEFT - else: - lower, upper = _V10_POS_LOWER_RIGHT, _V10_POS_UPPER_RIGHT - return JointLimits( - position_lower=list(lower), - position_upper=list(upper), - velocity_max=list(_V10_VEL_MAX), - ) - - def set_control_mode(self, mode: ControlMode) -> bool: - # OpenArm runs exclusively in Damiao MIT register mode; we emulate - # dimos ControlModes by tuning kp/kd/q/dq/tau on each MIT frame. - # Cartesian/impedance control are outside this adapter's scope. - if mode in ( - ControlMode.POSITION, - ControlMode.SERVO_POSITION, - ControlMode.VELOCITY, - ControlMode.TORQUE, - ): - self._control_mode = mode - return True - return False - - def get_control_mode(self) -> ControlMode: - return self._control_mode - def _states_or_raise(self) -> list[Any]: # Raises on missing or stale data so hardware_interface.py can retry # (init) or skip the tick (steady-state). @@ -253,7 +211,7 @@ def _states_or_raise(self) -> list[Any]: for i, s in enumerate(states): if s is None: raise RuntimeError(f"motor {i + 1} has no state yet") - if now - s.timestamp > _STATE_MAX_AGE_S: + if now - s.timestamp > self._STATE_MAX_AGE_S: age_ms = (now - s.timestamp) * 1000 raise RuntimeError(f"motor {i + 1} state stale ({age_ms:.0f} ms)") return states @@ -292,18 +250,6 @@ def read_error(self) -> tuple[int, str]: return 1, f"rotor over-temperature ({t_rotor}°C)" return 0, "" - def _compute_gravity_torques(self, q: list[float]) -> list[float]: - # Pinocchio G(q), clamped to motor torque limits. - if self._pin_model is None or self._pin_data is None: - return [0.0] * self._dof - import pinocchio - - q_arr = np.array(q, dtype=np.float64) - tau_g = pinocchio.computeGeneralizedGravity(self._pin_model, self._pin_data, q_arr) - # Clamp to motor torque limits for safety - limits = [m.limits for m in self._motors] # (p_max, v_max, t_max) - return [float(np.clip(tau_g[i], -lim[2], lim[2])) for i, lim in enumerate(limits)] - def write_joint_positions( self, positions: list[float], @@ -320,7 +266,7 @@ def write_joint_positions( # back to commanded q with no feedforward instead of crashing. try: q_current = self.read_joint_positions() - tau_ff = self._compute_gravity_torques(q_current) + tau_ff = self.compute_gravity_torques(q_current) except RuntimeError: tau_ff = [0.0] * self._dof commands = [ @@ -350,7 +296,7 @@ def write_joint_velocities(self, velocities: list[float]) -> bool: anchor = self._last_cmd_q try: q_current = self.read_joint_positions() - tau_ff = self._compute_gravity_torques(q_current) + tau_ff = self.compute_gravity_torques(q_current) except RuntimeError: tau_ff = [0.0] * self._dof commands = [ @@ -369,7 +315,7 @@ def write_stop(self) -> bool: q_now = self.read_joint_positions() except RuntimeError: return False - tau_ff = self._compute_gravity_torques(q_now) + tau_ff = self.compute_gravity_torques(q_now) commands = [ (q, 0.0, kp, kd, tau) for q, kp, kd, tau in zip(q_now, self._kp, self._kd, tau_ff, strict=False) @@ -392,9 +338,6 @@ def write_enable(self, enable: bool) -> bool: self._enabled = enable return True - def read_enabled(self) -> bool: - return self._enabled - def write_clear_errors(self) -> bool: # Damiao motors have no separate clear-error command; re-enabling # after a fault is the recovery path. @@ -409,21 +352,6 @@ def write_clear_errors(self) -> bool: self._enabled = True return True - def read_cartesian_position(self) -> dict[str, float] | None: - return None - - def write_cartesian_position(self, pose: dict[str, float], velocity: float = 1.0) -> bool: - return False - - def read_gripper_position(self) -> float | None: - return None - - def write_gripper_position(self, position: float) -> bool: - return False - - def read_force_torque(self) -> list[float] | None: - return None - # ── Registry hook (required for auto-discovery) ─────────────────── def register(registry: AdapterRegistry) -> None: diff --git a/dimos/hardware/manipulators/openarm/test_adapter.py b/dimos/hardware/manipulators/openarm/test_adapter.py new file mode 100644 index 0000000000..f70a2c2f91 --- /dev/null +++ b/dimos/hardware/manipulators/openarm/test_adapter.py @@ -0,0 +1,157 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase +from dimos.hardware.manipulators.openarm.adapter import OpenArmAdapter, register +from dimos.hardware.manipulators.spec import ControlMode, ManipulatorAdapter + + +class FakeState: + def __init__(self, index: int) -> None: + import time + + self.q = 0.1 * index + self.dq = 0.2 * index + self.tau = 0.3 * index + self.t_rotor = 30 + index + self.timestamp = time.monotonic() + + +class FakeOpenArmBus: + last: FakeOpenArmBus | None = None + + def __init__(self, channel: str, motors: list[object], *, fd: bool, interface: str) -> None: + FakeOpenArmBus.last = self + self.channel = channel + self.motors = motors + self.fd = fd + self.interface = interface + self.opened = False + self.closed = False + self.disabled_count = 0 + self.ctrl_mode_ids: list[int] = [] + self.mit_commands: list[list[tuple[float, float, float, float, float]]] = [] + self.states = [FakeState(index) for index in range(len(motors))] + + def open(self) -> None: + self.opened = True + + def close(self) -> None: + self.closed = True + + def write_ctrl_mode(self, send_id: int, mode: int) -> None: + self.ctrl_mode_ids.append(send_id) + + def get_states(self) -> list[FakeState]: + return self.states + + def send_mit_many(self, commands: list[tuple[float, float, float, float, float]]) -> None: + self.mit_commands.append(commands) + + def enable_all(self) -> None: + return None + + def disable_all(self) -> None: + self.disabled_count += 1 + + +def test_implements_manipulator_adapter() -> None: + assert isinstance(OpenArmAdapter(gravity_comp=False), ManipulatorAdapter) + assert isinstance(OpenArmAdapter(gravity_comp=False), DamiaoArmAdapterBase) + + +def test_register_preserves_openarm_key() -> None: + registry = MagicMock() + register(registry) + registry.register.assert_called_once_with("openarm", OpenArmAdapter) + + +def test_constructor_validates_dof_side_and_gain_lengths() -> None: + with pytest.raises(ValueError, match="only supports 7 DOF"): + OpenArmAdapter(dof=6, gravity_comp=False) + with pytest.raises(ValueError, match="side must be 'left' or 'right'"): + OpenArmAdapter(side="middle", gravity_comp=False) + with pytest.raises(ValueError, match="kp length 1 does not match dof 7"): + OpenArmAdapter(kp=[1.0], gravity_comp=False) + with pytest.raises(ValueError, match="kd length 1 does not match dof 7"): + OpenArmAdapter(kd=[1.0], gravity_comp=False) + + +def test_info_limits_and_modes_match_openarm_sides() -> None: + left = OpenArmAdapter(side="left", gravity_comp=False) + right = OpenArmAdapter(side="right", gravity_comp=False) + assert left.get_info().vendor == "Enactic" + assert left.get_info().model == "OpenArm v10 (left)" + assert right.get_info().model == "OpenArm v10 (right)" + assert left.get_limits().position_lower[:2] == pytest.approx([-3.45, -3.30]) + assert right.get_limits().position_lower[:2] == pytest.approx([-1.35, -0.15]) + assert left.set_control_mode(ControlMode.VELOCITY) is True + assert left.get_control_mode() == ControlMode.VELOCITY + assert left.set_control_mode(ControlMode.CARTESIAN) is False + + +def test_disconnected_surface_returns_safe_defaults() -> None: + adapter = OpenArmAdapter(gravity_comp=False) + assert adapter.is_connected() is False + assert adapter.read_state() == {"state": 0, "mode": 0} + assert adapter.read_error() == (0, "") + assert adapter.write_joint_positions([0.0] * 7) is False + assert adapter.write_joint_velocities([0.0] * 7) is False + assert adapter.write_stop() is False + assert adapter.write_enable(True) is False + assert adapter.write_clear_errors() is False + assert adapter.read_cartesian_position() is None + assert adapter.write_cartesian_position({}) is False + assert adapter.read_gripper_position() is None + assert adapter.write_gripper_position(0.0) is False + assert adapter.read_force_torque() is None + + +def test_lifecycle_state_commands_and_disconnect(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "dimos.hardware.manipulators.openarm.adapter.OpenArmBus", + FakeOpenArmBus, + ) + adapter = OpenArmAdapter(interface="virtual", gravity_comp=False) + + assert adapter.connect() is True + bus = FakeOpenArmBus.last + assert bus is not None + assert bus.opened is True + assert bus.ctrl_mode_ids == [1, 2, 3, 4, 5, 6, 7] + + assert adapter.write_enable(True) is True + assert adapter.read_enabled() is True + assert adapter.read_joint_positions() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + assert adapter.read_joint_velocities() == pytest.approx([0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2]) + assert adapter.read_joint_efforts() == pytest.approx([0.0, 0.3, 0.6, 0.9, 1.2, 1.5, 1.8]) + assert adapter.read_state()["t_rotor_max"] == 36 + + assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True + assert bus.mit_commands[-1][0] == pytest.approx((0.1, 0.0, 50.0, 1.5, 0.0)) + assert adapter.write_joint_velocities([0.2] * 7) is True + assert bus.mit_commands[-1][0] == pytest.approx((0.1, 0.2, 0.0, 1.5, 0.0)) + assert adapter.write_stop() is True + assert bus.mit_commands[-1][0] == pytest.approx((0.0, 0.0, 100.0, 1.5, 0.0)) + + adapter.disconnect() + assert adapter.read_enabled() is False + assert bus.closed is True + assert bus.disabled_count == 1 diff --git a/docs/capabilities/manipulation/openarm_integration.md b/docs/capabilities/manipulation/openarm_integration.md index 4a516c472c..47f7449a89 100644 --- a/docs/capabilities/manipulation/openarm_integration.md +++ b/docs/capabilities/manipulation/openarm_integration.md @@ -22,7 +22,7 @@ Every other arm in dimos wraps a vendor Python SDK: | Go2 / G1 | WebRTC | Unitree SDK | | Panda | FCI | `panda-py` | -**OpenArm historically shipped no stable Python SDK.** The default `openarm` adapter still uses dimos' in-tree raw-CAN driver and remains the existing production path. DimOS also provides an opt-in `dm_motor_arm` adapter for environments that already provide the Rust-backed `dm_control` Python binding; this change does not install that binding. +**OpenArm historically shipped no stable Python SDK.** The default `openarm` adapter still uses dimos' in-tree raw-CAN driver and remains the existing production path. It is now implemented as an explicit OpenArm subclass over shared Damiao metadata and gravity/validation helpers, so users still select `adapter_type="openarm"` for OpenArm hardware. DimOS also provides an opt-in `dm_motor_arm` adapter for environments that already provide the Rust-backed `dm_control` Python binding; this change does not install that binding. ## Adapter paths @@ -31,7 +31,7 @@ Every other arm in dimos wraps a vendor Python SDK: | `openarm` | In-tree SocketCAN Damiao driver | `python-can` plus Pinocchio for gravity feed-forward | Existing OpenArm coordinator, planner, and teleop blueprints. | | `dm_motor_arm` | Rust-backed `dm_control` Python binding | Binding must already be importable in the active environment | DMMotor bring-up, binding-backed coordinator operation, and gravity-compensation-only validation. | -Selecting `dm_motor_arm` is explicit through blueprint or hardware config. Registry discovery remains available without `dm_control`; selecting the adapter fails with a clear missing-binding error if the package is absent. +Selecting `dm_motor_arm` is explicit through blueprint or hardware config. Registry discovery remains available without `dm_control`; selecting the adapter fails with a clear missing-binding error if the package is absent. Future Damiao-based arms should subclass the shared Damiao adapter base with their own typed motor/gain/limit metadata instead of relying on OpenArm defaults. ## Architecture diff --git a/openspec/changes/refactor-damiao-arm-adapters/.openspec.yaml b/openspec/changes/refactor-damiao-arm-adapters/.openspec.yaml new file mode 100644 index 0000000000..c66f6f490c --- /dev/null +++ b/openspec/changes/refactor-damiao-arm-adapters/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-05 diff --git a/openspec/changes/refactor-damiao-arm-adapters/design.md b/openspec/changes/refactor-damiao-arm-adapters/design.md new file mode 100644 index 0000000000..7aef941840 --- /dev/null +++ b/openspec/changes/refactor-damiao-arm-adapters/design.md @@ -0,0 +1,126 @@ +## Context + +DimOS manipulator hardware is selected through `HardwareComponent.adapter_type`, constructed by the manipulator adapter registry, and consumed by `ControlCoordinator` through the existing `ManipulatorAdapter` protocol. Current OpenArm support includes an `openarm` adapter with an in-tree Damiao/CAN implementation and a newer `dm_motor_arm` binding-backed adapter path. + +The current code already has enough configuration hooks for one OpenArm-style DMMotor arm, but the reusable Damiao behavior and OpenArm-specific assumptions are not separated clearly. A fully dynamic URDF-plus-sidecar config loader was considered, but the desired scope is smaller: keep OpenArm explicit, preserve existing OpenArm adapter behavior, and make future Damiao arms straightforward to add by subclassing a shared base. + +Relevant surfaces include `dimos/hardware/manipulators/openarm/adapter.py`, `dimos/hardware/manipulators/dm_motor_arm/adapter.py`, `dimos/hardware/manipulators/registry.py`, `dimos/robot/catalog/openarm.py`, `dimos/robot/manipulators/openarm/blueprints.py`, and `docs/capabilities/manipulation/openarm_integration.md`. + +## Goals / Non-Goals + +**Goals:** + +- Keep `OpenArmAdapter` as the explicit OpenArm adapter and keep `adapter_type="openarm"` stable. +- Extract shared Damiao arm behavior into one reusable base used by OpenArm and any future Damiao-arm subclasses. +- Represent per-arm constants with typed class-level specs rather than broad user-editable sidecar config. +- Preserve existing ControlCoordinator, stream, blueprint, and manipulation task contracts. +- Preserve the explicit `dm_motor_arm` adapter path where it remains useful for binding-backed bring-up. +- Keep hardware safety behavior at least as conservative as the current OpenArm and DMMotor adapters. + +**Non-Goals:** + +- Do not introduce a new runtime-configurable YAML/TOML sidecar schema for arbitrary users in this change. +- Do not remove the existing `openarm` adapter registration. +- Do not silently migrate all OpenArm blueprints to `dm_motor_arm`. +- Do not add dependency installation or package-management changes for `dm_control`. +- Do not change the public ControlCoordinator stream contract or add new MCP/skill tools. +- Do not implement ros2_control or transmission parsing. + +## DimOS Architecture + +The implementation should keep the same external coordinator shape: + +```text +ControlCoordinator + -> HardwareComponent(adapter_type="openarm" | "dm_motor_arm") + -> manipulator adapter registry + -> OpenArmAdapter or DMMotorArm + -> shared Damiao base behavior + -> dm_control binding or existing OpenArm CAN bus path, depending on adapter path +``` + +The shared base should be an internal hardware-layer abstraction, not a new DimOS `Spec` Protocol. The public adapter contract remains `ManipulatorAdapter` from `dimos/hardware/manipulators/spec.py`. No new module streams, transports, or RPC injection contracts are required. + +Recommended code shape: + +```text +dimos/hardware/manipulators/damiao/ + specs.py # typed DamiaoJointSpec / DamiaoArmSpec-style data + base_adapter.py # shared lifecycle/state/command/gravity behavior + +dimos/hardware/manipulators/openarm/adapter.py + OpenArmAdapter(DamiaoArmAdapterBase) + OpenArm v10 left/right specs and OpenArm-specific behavior + +dimos/hardware/manipulators/dm_motor_arm/adapter.py + DMMotorArm(DamiaoArmAdapterBase) or thin compatibility wrapper around the base +``` + +Exact module names can change during implementation, but the boundary should not: common Damiao behavior belongs in one shared hardware-layer implementation, while OpenArm-specific values stay in the OpenArm adapter/catalog layer. + +Blueprints should continue to compose `ControlCoordinator.blueprint(...)` with hardware components generated from existing `RobotConfig` catalog helpers. If runnable blueprint names change, regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py`. If no runnable names change, registry generation is not required. + +## Decisions + +- **Use shallow inheritance, not a dynamic sidecar loader.** A single shared base plus per-arm subclasses matches the current need and keeps new-arm support code-defined and type-checkable. +- **Keep OpenArm explicit.** Existing users select `adapter_type="openarm"`; preserving that surface avoids a silent hardware behavior change. +- **Use typed arm specs for constants.** Motor names, motor types, send/recv IDs, gains, limits, URDF/gravity-model paths, and side-specific metadata should live in immutable data structures owned by subclasses. +- **Keep the ControlCoordinator contract unchanged.** The refactor should be invisible to tasks and stream consumers: joint state and command routing continue through the same surfaces. +- **Do not make URDF the only source of hardware truth.** URDF remains useful for model/gravity/planning, but Damiao motor IDs, CAN details, gains, and signs are hardware facts supplied by subclass specs. +- **Retain lazy optional imports.** Adapters that need optional bindings must not break registry discovery when those packages are unavailable. +- **Prefer composition inside the base over deep inheritance.** One base class is enough; avoid multi-level adapter hierarchies or mixin webs. + +## Safety / Simulation / Replay + +Hardware assumptions: + +- OpenArm hardware continues to run through the existing `openarm` adapter unless a blueprint explicitly selects a different adapter. +- DMMotor/Damiao hardware may require Linux SocketCAN, CAN-FD or classical CAN depending on adapter path, and staged bring-up before full-arm control. +- Gravity compensation uses a configured model path and current measured joint state; incorrect model/sign/offset assumptions can push hardware unexpectedly. + +Safety constraints: + +- Preserve disable-on-disconnect and stop behavior. +- Preserve or improve state freshness/coherence checks before command writes and gravity compensation. +- Do not introduce background command loops that keep running after adapter disconnect. +- Keep gravity-compensation-only behavior free-moving by using zero position stiffness where applicable. +- Validate mock/vcan behavior before real hardware, then one-motor or single-arm staged bring-up before full operation. + +Simulation/replay: + +- Replay is out of scope. +- Mock/vcan adapter tests should cover the refactored shared base. +- Existing OpenArm mock/planner blueprints should remain available. + +Manual QA surface: + +- Registry discovery includes `openarm` and any retained `dm_motor_arm` key. +- Existing OpenArm mock blueprint still builds and runs through the coordinator surface. +- Binding-unavailable behavior remains explicit when selecting binding-backed adapters. +- Mock/vcan DMMotor adapter can connect, enable, read one coherent state snapshot, write position/effort/MIT commands, and disconnect safely. + +## Risks / Trade-offs + +- **Accidental behavior drift:** Moving logic under `OpenArmAdapter` can subtly change current OpenArm behavior. Mitigation: focused before/after adapter tests for limits, gains, motor specs, lifecycle, and command shapes. +- **Over-abstracting too early:** A generic base can become too broad. Mitigation: support only the behavior already shared by OpenArm/DMMotor adapters and keep subclass specs explicit. +- **Optional binding confusion:** `openarm` and `dm_motor_arm` may use different low-level paths. Mitigation: document the distinction and keep missing-binding errors tied only to selected binding-backed adapters. +- **Gravity model mismatch:** Shared gravity code can hide per-arm assumptions. Mitigation: subclass specs must provide model path and torque limits explicitly where gravity compensation is enabled. + +## Migration / Rollout + +1. Add the shared Damiao spec/base layer without changing blueprint names. +2. Move `OpenArmAdapter` onto the shared base while preserving its constructor arguments, registration key, limits, gains, and side behavior. +3. Refactor `DMMotorArm` to reuse the same shared behavior or become a thin compatibility wrapper. +4. Keep existing OpenArm catalog and blueprint selection stable. +5. Add focused tests for shared base behavior, OpenArm compatibility, and retained `dm_motor_arm` behavior. +6. Update OpenArm/manipulation docs to explain that OpenArm is a subclass/preset over shared Damiao behavior. +7. Regenerate blueprint registry only if runnable blueprint names or exported blueprint variables change. + +Rollback is straightforward if the refactor is isolated: keep the previous adapter classes intact until parity tests pass, and avoid changing external blueprint names during the first implementation pass. + +## Open Questions + +- Should the shared base live under `dimos/hardware/manipulators/damiao/` or inside the existing `dm_motor_arm` package? +- Should `DMMotorArm` remain a public generic adapter key, or become an internal compatibility alias after OpenArm uses the shared base? +- Should the initial shared base target only the binding-backed path, or also absorb the in-tree OpenArm CAN driver path? +- How much of OpenArm's current velocity-mode behavior should be retained if the binding-backed path rejects velocity commands? diff --git a/openspec/changes/refactor-damiao-arm-adapters/docs.md b/openspec/changes/refactor-damiao-arm-adapters/docs.md new file mode 100644 index 0000000000..ae3c40730a --- /dev/null +++ b/openspec/changes/refactor-damiao-arm-adapters/docs.md @@ -0,0 +1,25 @@ +## User-Facing Docs + +- Update `docs/capabilities/manipulation/openarm_integration.md` to explain that `openarm` remains the explicit OpenArm adapter while shared Damiao behavior lives underneath it. +- Clarify the distinction between the existing `openarm` adapter path and the opt-in `dm_motor_arm` binding-backed path if both remain user-selectable. +- Update any OpenArm bring-up section only if constructor kwargs, blueprint names, or recommended validation order changes. + +## Contributor Docs + +- No broad contributor documentation is required unless the implementation introduces a reusable pattern that future adapter authors should follow. +- If the shared Damiao base becomes a recommended extension point, add a short note to manipulation contributor docs or the manipulator driver README describing how to add a Damiao-based arm subclass. + +## Coding-Agent Docs + +- No AGENTS.md change is required for the refactor itself. +- If implementation creates a reusable adapter pattern that coding agents should prefer, update `docs/coding-agents/` only after the pattern stabilizes in code and tests. + +## Doc Validation + +- Run documentation link validation for changed docs if available in the repo workflow. +- Run `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` only if executable code blocks in that document are added or changed. +- If blueprint names are changed in docs, also run `pytest dimos/robot/test_all_blueprints_generation.py` after registry regeneration. + +## No Docs Needed + +Documentation updates are needed if the user-visible adapter distinction or extension story changes. If implementation is purely internal and preserves all documented names/commands, docs can be limited to a short architecture note in the OpenArm integration guide. diff --git a/openspec/changes/refactor-damiao-arm-adapters/proposal.md b/openspec/changes/refactor-damiao-arm-adapters/proposal.md new file mode 100644 index 0000000000..45d8590695 --- /dev/null +++ b/openspec/changes/refactor-damiao-arm-adapters/proposal.md @@ -0,0 +1,39 @@ +## Why + +The new `dm_motor_arm` path proves that DimOS can operate Damiao/DMMotor arms through the binding-backed adapter, but the current shape is awkward for future Damiao-based arms: shared Damiao behavior and OpenArm-specific assumptions are mixed together. The earlier idea of fully dynamic URDF-plus-sidecar configuration is more flexible than this project currently needs and would add a larger configuration surface than necessary. + +This change should refactor the adapter structure around a small class hierarchy: keep `OpenArmAdapter` as the explicit OpenArm adapter, extract reusable Damiao/CAN/MIT behavior into a shared base, and let future Damiao arms opt in by subclassing with their own class-level arm specs. + +## What Changes + +- Add a reusable Damiao arm adapter base that owns generic binding/CAN lifecycle, state reads, MIT command writes, gravity compensation plumbing, and shared validation. +- Keep `OpenArmAdapter` and the `openarm` adapter registration as the stable OpenArm-specific surface. +- Move OpenArm-specific motor tables, URDF paths, gains, limits, side handling, and model metadata into the OpenArm subclass/spec rather than keeping them as generic `DMMotorArm` defaults. +- Preserve the opt-in `dm_motor_arm` adapter path where useful, but make its relationship to the shared Damiao base explicit. +- Avoid introducing a broad user-editable sidecar schema in this change; new Damiao arms can be added through typed subclasses and catalog/blueprint presets. +- No **BREAKING** public CLI or blueprint behavior is intended; existing OpenArm blueprints should keep selecting `openarm` unless explicitly migrated. + +## Affected DimOS Surfaces + +- Modules/streams: Manipulator adapter implementations behind the existing `ManipulatorAdapter` protocol; ControlCoordinator stream behavior should remain unchanged. +- Blueprints/CLI: Existing OpenArm blueprints and `dimos run` entries should remain stable; opt-in DMMotor/OpenArm blueprints may be adjusted only to use the refactored adapter classes. +- Skills/MCP: No direct skill or MCP changes planned. +- Hardware/simulation/replay: Real OpenArm/Damiao hardware lifecycle, mock/vcan validation, gravity compensation, enable/disable safety, and command semantics. +- Docs/generated registries: OpenArm/manipulation documentation may need updates to explain the adapter split; blueprint registry regeneration is needed only if runnable blueprint names change. + +## Capabilities + +### New Capabilities + +- `damiao-arm-adapter-refactor`: Covers the reusable Damiao arm adapter structure, subclass-based arm specs, and preservation of OpenArm adapter behavior. +- `openarm-adapter-compatibility`: Covers compatibility expectations for existing OpenArm adapter registration, blueprints, hardware behavior, and staged QA. + +### Modified Capabilities + +- None. + +## Impact + +Developers get a simpler extension path for additional Damiao-based arms without introducing a large dynamic configuration format. OpenArm remains a named adapter with explicit OpenArm behavior, while shared Damiao code becomes easier to test and reuse. + +Compatibility risk is mainly around preserving the current `openarm` adapter semantics while moving shared logic underneath it. Hardware safety QA must cover enable/disable, state read coherence, position/effort/MIT commands, gravity-compensation behavior, and mock/vcan smoke tests before real hardware validation. No new runtime dependency installation is planned. diff --git a/openspec/changes/refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md b/openspec/changes/refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md new file mode 100644 index 0000000000..8a99cf6d67 --- /dev/null +++ b/openspec/changes/refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Reusable Damiao arm behavior + +DimOS SHALL provide a reusable Damiao arm adapter behavior that can be shared by OpenArm and future Damiao-based manipulator adapters without requiring users to define a broad dynamic hardware configuration schema. + +#### Scenario: Adding a Damiao-based arm implementation +- **GIVEN** a developer needs to add a new arm composed of Damiao motors +- **WHEN** the arm behavior fits the shared Damiao lifecycle, state-read, and MIT command model +- **THEN** the developer SHALL be able to define an arm-specific implementation by providing typed arm metadata and subclass-specific behavior +- **AND** they SHALL NOT need to duplicate the shared Damiao lifecycle and command plumbing. + +#### Scenario: Preserving coordinator behavior +- **GIVEN** a Damiao-based adapter uses the shared behavior +- **WHEN** ControlCoordinator reads state or writes supported joint commands through the manipulator adapter surface +- **THEN** DimOS SHALL preserve the existing manipulator state and command semantics +- **AND** no new stream contract SHALL be required for the refactor. + +### Requirement: Explicit arm-specific metadata + +DimOS SHALL keep arm-specific Damiao metadata explicit and typed rather than relying on implicit OpenArm defaults inside generic Damiao behavior. + +#### Scenario: Constructing a non-OpenArm Damiao adapter +- **GIVEN** a Damiao-based adapter does not represent OpenArm +- **WHEN** it is implemented using the shared Damiao behavior +- **THEN** it SHALL provide its own motor layout, gains, joint order, model/gravity metadata, and hardware assumptions +- **AND** DimOS SHALL NOT silently use OpenArm's motor table or OpenArm-specific defaults for that adapter. + +#### Scenario: Validating shared command shape +- **GIVEN** a Damiao adapter accepts a supported position, effort, or MIT-style command +- **WHEN** the command is forwarded to hardware or a mock transport +- **THEN** the command SHALL be ordered according to the adapter's explicit arm metadata +- **AND** command length mismatches SHALL be rejected or surfaced as adapter errors rather than sent to hardware. + +### Requirement: Optional binding behavior remains selected-adapter scoped + +DimOS SHALL keep optional Damiao binding availability failures scoped to adapters that actually select the binding-backed path. + +#### Scenario: Binding unavailable while discovering adapters +- **GIVEN** an optional Damiao binding is not importable in the runtime environment +- **WHEN** DimOS discovers manipulator adapters +- **THEN** adapter discovery SHALL continue for adapters that do not require that binding at discovery time +- **AND** missing-binding errors SHALL be raised only when selecting or connecting an adapter that requires the binding. diff --git a/openspec/changes/refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md b/openspec/changes/refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md new file mode 100644 index 0000000000..d0092d7f6d --- /dev/null +++ b/openspec/changes/refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: OpenArm adapter compatibility + +DimOS SHALL preserve the existing OpenArm adapter selection and observable behavior while refactoring shared Damiao behavior underneath it. + +#### Scenario: Selecting the OpenArm adapter +- **GIVEN** an existing hardware configuration selects the OpenArm adapter +- **WHEN** DimOS initializes the manipulator hardware +- **THEN** DimOS SHALL continue to create an OpenArm-specific adapter through the existing adapter selection surface +- **AND** the adapter SHALL preserve OpenArm-specific side, joint, motor, limit, gain, and gravity-model behavior. + +#### Scenario: Existing OpenArm blueprints +- **GIVEN** an existing OpenArm blueprint selects the current OpenArm adapter path +- **WHEN** this refactor is present +- **THEN** the blueprint SHALL continue selecting the OpenArm adapter unless it is explicitly changed +- **AND** existing runnable blueprint names SHALL remain stable unless a generated-registry update intentionally changes them. + +### Requirement: OpenArm hardware safety preservation + +OpenArm adapter behavior SHALL remain at least as safe as the pre-refactor behavior for enablement, command writes, gravity compensation, and shutdown. + +#### Scenario: Stopping or disconnecting OpenArm hardware +- **GIVEN** OpenArm motors are enabled through DimOS +- **WHEN** the adapter is stopped or disconnected +- **THEN** DimOS SHALL attempt to disable or stop commanding the motors through the adapter +- **AND** the refactor SHALL NOT introduce a continued background command loop after disconnect. + +#### Scenario: OpenArm gravity compensation +- **GIVEN** OpenArm gravity compensation is enabled +- **WHEN** DimOS computes and sends supported OpenArm commands +- **THEN** gravity feed-forward SHALL use the OpenArm-specific model and current measured joint state +- **AND** invalid or stale state SHALL prevent unsafe gravity-compensation commands from being sent. + +### Requirement: Staged OpenArm validation + +DimOS SHALL support staged validation of refactored OpenArm adapter behavior before real full-arm use. + +#### Scenario: Mock or virtual validation +- **GIVEN** the refactored OpenArm or Damiao adapter path is available +- **WHEN** a developer validates the change without real hardware +- **THEN** DimOS SHALL support mock or virtual transport tests for lifecycle, state reads, supported commands, and shutdown +- **AND** real hardware validation SHALL be treated as a later staged QA step. diff --git a/openspec/changes/refactor-damiao-arm-adapters/tasks.md b/openspec/changes/refactor-damiao-arm-adapters/tasks.md new file mode 100644 index 0000000000..04f6425869 --- /dev/null +++ b/openspec/changes/refactor-damiao-arm-adapters/tasks.md @@ -0,0 +1,43 @@ +## 1. Shared Damiao Adapter Structure + +- [x] 1.1 Add typed Damiao arm metadata structures for motor layout, gains, limits, gravity model, and optional binding/bus assumptions. +- [x] 1.2 Add a shared Damiao arm adapter base that centralizes reusable lifecycle, state-read, command-write, validation, and gravity-compensation behavior. +- [x] 1.3 Keep optional binding imports lazy so adapter discovery does not fail when optional Damiao bindings are unavailable. +- [x] 1.4 Add tests for shared metadata validation, including duplicate IDs, command length mismatch, joint-order preservation, and missing optional binding behavior. + +## 2. OpenArm Compatibility Refactor + +- [x] 2.1 Refactor `OpenArmAdapter` to use the shared Damiao base while preserving the `openarm` adapter registration key. +- [x] 2.2 Move OpenArm v10 motor tables, side-specific URDF/gravity paths, gains, limits, and side handling into OpenArm-specific typed specs. +- [x] 2.3 Preserve OpenArm constructor arguments and externally observable behavior for existing OpenArm hardware configs. +- [x] 2.4 Add focused OpenArm adapter parity tests for info, limits, gains, motor specs, lifecycle, state reads, supported command shapes, gravity compensation, and disconnect/stop behavior. + +## 3. DMMotor Adapter Alignment + +- [x] 3.1 Refactor `DMMotorArm` to reuse the shared Damiao base or become a thin compatibility wrapper around it. +- [x] 3.2 Preserve `dm_motor_arm` adapter registration and selected-adapter missing-binding errors. +- [x] 3.3 Preserve binding-backed mock/vcan behavior for connect, enable, coherent state reads, position writes, effort/MIT writes, gravity-only commands, and safe disconnect. +- [x] 3.4 Add focused `dm_motor_arm` tests proving OpenArm defaults are not treated as generic defaults for non-OpenArm Damiao subclasses. + +## 4. Blueprints and Catalogs + +- [x] 4.1 Keep existing OpenArm catalog helpers and blueprint names stable unless an explicit migration is required. +- [x] 4.2 Update opt-in DMMotor/OpenArm blueprint wiring only if needed to select the refactored adapter classes. +- [x] 4.3 Run `pytest dimos/robot/test_all_blueprints_generation.py` and update `dimos/robot/all_blueprints.py` only if runnable blueprint exports change. + +## 5. Documentation + +- [x] 5.1 Update `docs/capabilities/manipulation/openarm_integration.md` to explain that `openarm` remains explicit while shared Damiao behavior lives underneath it. +- [x] 5.2 Clarify the distinction between `openarm` and `dm_motor_arm` adapter paths if both remain user-selectable. +- [x] 5.3 Update manipulator driver/contributor docs only if the shared Damiao base becomes the recommended extension point for future Damiao arms. + +## 6. Verification + +- [x] 6.1 Run `openspec validate refactor-damiao-arm-adapters`. +- [x] 6.2 Run focused tests for `dimos/hardware/manipulators/openarm/`. +- [x] 6.3 Run focused tests for `dimos/hardware/manipulators/dm_motor_arm/` and any new shared Damiao adapter package. +- [x] 6.4 Run focused ControlCoordinator or hardware-interface tests if adapter/coordinator integration behavior changes. +- [x] 6.5 Run docs validation for changed docs, including `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable blocks are added or changed. +- [x] 6.6 Manually QA registry discovery through the library surface by confirming `openarm` and retained `dm_motor_arm` adapter keys are available. +- [x] 6.7 Manually QA mock/vcan adapter operation through the library or coordinator surface: connect, enable, read state, write a supported command, stop, and disconnect. +- [ ] 6.8 Manually QA real OpenArm hardware only after mock/vcan validation: one-arm enable/read, low-rate hold or gravity compensation, safe stop, and optional trajectory-control validation. From d409fcc53402cbf893b6fa3677ef51d909723b8c Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 08:09:02 -0700 Subject: [PATCH 005/110] fix: use can motor control package --- .../manipulators/dm_motor_arm/adapter.py | 37 +++++++++---------- .../manipulators/dm_motor_arm/test_adapter.py | 34 ++++++++++++----- .../manipulation/openarm_integration.md | 8 ++-- .../add-dm-motor-arm-adapter/design.md | 27 +++++++------- .../changes/add-dm-motor-arm-adapter/docs.md | 8 ++-- .../add-dm-motor-arm-adapter/proposal.md | 12 +++--- .../dm-motor-manipulator-adapters/spec.md | 8 ++-- .../changes/add-dm-motor-arm-adapter/tasks.md | 8 ++-- .../refactor-damiao-arm-adapters/design.md | 4 +- pyproject.toml | 3 +- uv.lock | 17 +++++++++ 11 files changed, 99 insertions(+), 67 deletions(-) diff --git a/dimos/hardware/manipulators/dm_motor_arm/adapter.py b/dimos/hardware/manipulators/dm_motor_arm/adapter.py index 88edb908df..def83e8060 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/adapter.py +++ b/dimos/hardware/manipulators/dm_motor_arm/adapter.py @@ -39,18 +39,17 @@ class DMMotorBindingUnavailableError(RuntimeError): pass -def _load_dm_control() -> tuple[ModuleType, ModuleType]: +def _load_can_motor_control() -> tuple[ModuleType, ModuleType]: try: - dm_control = importlib.import_module("dm_control") - damiao = importlib.import_module("dm_control.damiao") + can_motor_control = importlib.import_module("can_motor_control") + damiao = importlib.import_module("can_motor_control.damiao") except ImportError as exc: raise DMMotorBindingUnavailableError( - "The selected 'dm_motor_arm' adapter requires the Rust-backed dm_control " - "Python binding in the active environment. Install/provide that binding " - "before selecting adapter_type='dm_motor_arm'; DimOS will not install it " - "automatically." + "The selected 'dm_motor_arm' adapter requires the Rust-backed " + "can-motor-control Python binding in the active environment. Install " + "dimos[manipulation] before selecting adapter_type='dm_motor_arm'." ) from exc - return dm_control, damiao + return can_motor_control, damiao def _resolve_motor_type(damiao: ModuleType, motor_type: str | int | Any) -> Any: @@ -151,7 +150,7 @@ def __init__( self._tick_deadline_us = tick_deadline_us self._state_cache_ttl_s = state_cache_ttl_s - self._dm_control: ModuleType | None = None + self._can_motor_control: ModuleType | None = None self._damiao: ModuleType | None = None self._robot: Any = None self._arm: Any = None @@ -184,13 +183,13 @@ def _coerce_motor_specs( def connect(self) -> bool: try: - self._dm_control, self._damiao = _load_dm_control() + self._can_motor_control, self._damiao = _load_can_motor_control() self._robot = self._build_robot() self._robot.connect() self._arm = self._robot[self._arm_name] if len(self._arm) != self._dof: raise RuntimeError( - f"dm_control arm group {self._arm_name!r} has {len(self._arm)} joints, " + f"can_motor_control arm group {self._arm_name!r} has {len(self._arm)} joints, " f"expected {self._dof}" ) self._load_gravity_model() @@ -207,21 +206,21 @@ def connect(self) -> bool: return True def _build_robot(self) -> Any: - assert self._dm_control is not None + assert self._can_motor_control is not None assert self._damiao is not None if self._config_path is not None: - return self._dm_control.Robot.from_config(self._config_path) + return self._can_motor_control.Robot.from_config(self._config_path) transport = ( - self._dm_control.MockCanBus.new_fd(self._address) + self._can_motor_control.MockCanBus.new_fd(self._address) if self._use_mock_bus and self._fd - else self._dm_control.MockCanBus(self._address) + else self._can_motor_control.MockCanBus(self._address) if self._use_mock_bus - else self._dm_control.SocketCanBus(self._address, fd=self._fd) + else self._can_motor_control.SocketCanBus(self._address, fd=self._fd) ) codec = self._damiao.DamiaoCodec() binding_specs = [ - self._dm_control.MotorSpec( + self._can_motor_control.MotorSpec( spec.name, _resolve_motor_type(self._damiao, spec.type), spec.send_id, @@ -230,7 +229,7 @@ def _build_robot(self) -> Any: for spec in self._motor_specs ] return ( - self._dm_control.Robot.builder() + self._can_motor_control.Robot.builder() .add_bus(self._bus_name, transport, codec) .add_arm(self._arm_name, bus=self._bus_name, motors=binding_specs) .build() @@ -270,7 +269,7 @@ def refresh_state( self._arm.torques().astype(float).tolist(), ) if any(len(values) != self._dof for values in state): - raise RuntimeError("dm_control state length does not match configured DOF") + raise RuntimeError("can_motor_control state length does not match configured DOF") self._state_cache = state self._state_cache_time = time.monotonic() self._last_positions = list(state[0]) diff --git a/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py b/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py index 6ea15ff281..2999cc9a24 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py +++ b/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py @@ -170,11 +170,11 @@ def __init__(self, interface: str, fd: bool = False) -> None: @pytest.fixture(autouse=True) -def fake_dm_control(monkeypatch: pytest.MonkeyPatch) -> None: - fake_dm = FakeDMControl("dm_control") - fake_damiao = FakeDamiao("dm_control.damiao") - monkeypatch.setitem(__import__("sys").modules, "dm_control", fake_dm) - monkeypatch.setitem(__import__("sys").modules, "dm_control.damiao", fake_damiao) +def fake_can_motor_control(monkeypatch: pytest.MonkeyPatch) -> None: + fake_dm = FakeDMControl("can_motor_control") + fake_damiao = FakeDamiao("can_motor_control.damiao") + monkeypatch.setitem(__import__("sys").modules, "can_motor_control", fake_dm) + monkeypatch.setitem(__import__("sys").modules, "can_motor_control.damiao", fake_damiao) FakeRobot.last = None @@ -244,17 +244,33 @@ def test_motor_specs_use_binding_motor_type_values() -> None: assert adapter._robot.arm.dof == 2 -def test_missing_binding_fails_only_when_selected(monkeypatch: pytest.MonkeyPatch) -> None: +def test_renamed_can_motor_control_binding_is_used(monkeypatch: pytest.MonkeyPatch) -> None: + imported: list[str] = [] real_import_module = importlib.import_module - def fail_dm_control(name: str, package: str | None = None) -> ModuleType: + def track_can_motor_control(name: str, package: str | None = None) -> ModuleType: if name.startswith("dm_control"): raise ImportError(name) + imported.append(name) + return real_import_module(name, package) + + monkeypatch.setattr(importlib, "import_module", track_can_motor_control) + adapter = DMMotorArm(use_mock_bus=True) + assert adapter.connect() is True + assert imported[:2] == ["can_motor_control", "can_motor_control.damiao"] + + +def test_missing_binding_fails_only_when_selected(monkeypatch: pytest.MonkeyPatch) -> None: + real_import_module = importlib.import_module + + def fail_can_motor_control(name: str, package: str | None = None) -> ModuleType: + if name.startswith("can_motor_control"): + raise ImportError(name) return real_import_module(name, package) - monkeypatch.setattr(importlib, "import_module", fail_dm_control) + monkeypatch.setattr(importlib, "import_module", fail_can_motor_control) adapter = DMMotorArm(use_mock_bus=True) - with pytest.raises(DMMotorBindingUnavailableError, match="requires.*dm_control"): + with pytest.raises(DMMotorBindingUnavailableError, match="requires.*can-motor-control"): adapter.connect() diff --git a/docs/capabilities/manipulation/openarm_integration.md b/docs/capabilities/manipulation/openarm_integration.md index 47f7449a89..007492aa14 100644 --- a/docs/capabilities/manipulation/openarm_integration.md +++ b/docs/capabilities/manipulation/openarm_integration.md @@ -22,16 +22,16 @@ Every other arm in dimos wraps a vendor Python SDK: | Go2 / G1 | WebRTC | Unitree SDK | | Panda | FCI | `panda-py` | -**OpenArm historically shipped no stable Python SDK.** The default `openarm` adapter still uses dimos' in-tree raw-CAN driver and remains the existing production path. It is now implemented as an explicit OpenArm subclass over shared Damiao metadata and gravity/validation helpers, so users still select `adapter_type="openarm"` for OpenArm hardware. DimOS also provides an opt-in `dm_motor_arm` adapter for environments that already provide the Rust-backed `dm_control` Python binding; this change does not install that binding. +**OpenArm historically shipped no stable Python SDK.** The default `openarm` adapter still uses dimos' in-tree raw-CAN driver and remains the existing production path. It is now implemented as an explicit OpenArm subclass over shared Damiao metadata and gravity/validation helpers, so users still select `adapter_type="openarm"` for OpenArm hardware. DimOS also provides an opt-in `dm_motor_arm` adapter for environments that install the Rust-backed `can-motor-control` Python binding through the manipulation extra. ## Adapter paths | Adapter | Hardware API | Dependency expectation | Typical use | |---|---|---|---| | `openarm` | In-tree SocketCAN Damiao driver | `python-can` plus Pinocchio for gravity feed-forward | Existing OpenArm coordinator, planner, and teleop blueprints. | -| `dm_motor_arm` | Rust-backed `dm_control` Python binding | Binding must already be importable in the active environment | DMMotor bring-up, binding-backed coordinator operation, and gravity-compensation-only validation. | +| `dm_motor_arm` | Rust-backed `can-motor-control` Python binding | Install `dimos[manipulation]` so `can_motor_control` is importable | DMMotor bring-up, binding-backed coordinator operation, and gravity-compensation-only validation. | -Selecting `dm_motor_arm` is explicit through blueprint or hardware config. Registry discovery remains available without `dm_control`; selecting the adapter fails with a clear missing-binding error if the package is absent. Future Damiao-based arms should subclass the shared Damiao adapter base with their own typed motor/gain/limit metadata instead of relying on OpenArm defaults. +Selecting `dm_motor_arm` is explicit through blueprint or hardware config. Registry discovery remains available without `can_motor_control`; selecting the adapter fails with a clear missing-binding error if the package is absent. Future Damiao-based arms should subclass the shared Damiao adapter base with their own typed motor/gain/limit metadata instead of relying on OpenArm defaults. ## Architecture @@ -142,7 +142,7 @@ dimos run openarm-planner-coordinator For the `dm_motor_arm` binding path, stage validation before trajectory control: binding mock or vcan, one motor enable/read, one motor low-rate hold, full-arm state monitor, adapter gravity compensation, then trajectory-control validation. ```bash -# requires dm_control binding in the active environment +# requires the can-motor-control binding from dimos[manipulation] sudo MODE=fd ./dimos/robot/manipulators/openarm/scripts/openarm_can_up.sh can0 # read/trajectory coordinator: writes only after a task receives a command diff --git a/openspec/changes/add-dm-motor-arm-adapter/design.md b/openspec/changes/add-dm-motor-arm-adapter/design.md index cfab818ecf..542e1e6628 100644 --- a/openspec/changes/add-dm-motor-arm-adapter/design.md +++ b/openspec/changes/add-dm-motor-arm-adapter/design.md @@ -2,7 +2,7 @@ DimOS currently supports manipulators through the `ManipulatorAdapter` protocol, the manipulator adapter registry, `HardwareComponent.adapter_type`, and the `ControlCoordinator` read/compute/write loop. OpenArm hardware already has an `openarm` adapter that owns a custom Python SocketCAN driver, sets MIT mode, computes Pinocchio-based gravity feed-forward, and preserves existing OpenArm blueprints. -The new `dm_control` package in `/home/cc/codes/dm_control_rs` exposes a Python binding for DMMotor/Damiao hardware. Its Python API owns robot lifecycle and bus ticks through `Robot.connect()`, `Robot.enable()`, repeated `Robot.tick(...)`, group state reads, and arm commands. This change must use that Python binding from DimOS and must not add dependency installation or package-management changes yet. +The `can-motor-control` package exposes a Python binding for DMMotor/Damiao hardware. Its Python API owns robot lifecycle and bus ticks through `Robot.connect()`, `Robot.enable()`, repeated `Robot.tick(...)`, group state reads, and arm commands. This change must use that Python binding from DimOS and expose the published package through the manipulation extra. Gravity compensation should follow the existing OpenArm adapter pattern: it is computed in-place inside adapter command writes and controlled with an adapter flag. The gravity-compensation-only helper method must not act like a trajectory or stiff position-hold controller. @@ -10,18 +10,18 @@ Gravity compensation should follow the existing OpenArm adapter pattern: it is c **Goals:** -- Add a DMMotor manipulator adapter path named `DMMotorArm` that uses the `dm_control` Python binding. +- Add a DMMotor manipulator adapter path named `DMMotorArm` that uses the `can_motor_control` Python binding. - Register the adapter under a stable DimOS adapter key, expected to be `dm_motor_arm`. - Preserve existing `openarm` adapter and blueprint behavior unless a later change explicitly migrates it. - Provide adapter-level gravity compensation that sends model-based feed-forward torque and can be enabled or disabled with an adapter flag. -- Keep dependency installation out of scope; environments selecting the adapter must already provide the Python binding. +- Add the published `can-motor-control` dependency to the manipulation extra; environments selecting the adapter should install `dimos[manipulation]`. - Document and validate safe hardware bring-up, especially enable/disable, state freshness, and shutdown behavior. **Non-Goals:** - Do not call the Rust crates directly from DimOS. - Do not replace the existing `openarm` adapter registration in this change. -- Do not add pip/uv/maturin dependency installation to DimOS packaging yet. +- Do not build or vendor the Rust binding in DimOS; consume the published `can-motor-control` package. - Do not expose new skills or MCP tools. - Do not introduce a separate gravity-compensation module or blueprint. @@ -34,20 +34,20 @@ ControlCoordinator -> HardwareComponent(adapter_type="dm_motor_arm") -> manipulator adapter registry -> DMMotorArm - -> dm_control Python binding + -> can_motor_control Python binding -> SocketCAN / MockCanBus / vcan-backed DMMotor hardware ``` -The `DMMotorArm` class should satisfy `ManipulatorAdapter` for coordinator-compatible use. It should be discovered through the existing manipulator registry hook and created from `HardwareComponent` fields such as `address`, `dof`, `hardware_id`, and `adapter_kwargs`. The adapter module should not import `dm_control` at module import time; it should import lazily when the adapter is constructed or connected so unrelated DimOS users do not lose registry discovery when the binding is absent. +The `DMMotorArm` class should satisfy `ManipulatorAdapter` for coordinator-compatible use. It should be discovered through the existing manipulator registry hook and created from `HardwareComponent` fields such as `address`, `dof`, `hardware_id`, and `adapter_kwargs`. The adapter module should not import `can_motor_control` at module import time; it should import lazily when the adapter is constructed or connected so unrelated DimOS users do not lose registry discovery when the binding is absent. -The coordinator path should use existing `joint_state` output and command routing. The adapter must reconcile DimOS' separate read/write calls with `dm_control`'s queued-command plus `Robot.tick(...)` model by owning tick/cache semantics internally. Reads should return coherent cached positions, velocities, and efforts for one cycle rather than independently ticking three times. Writes should queue commands and flush/update at a controlled point so the CAN bus is not double-loaded. +The coordinator path should use existing `joint_state` output and command routing. The adapter must reconcile DimOS' separate read/write calls with `can_motor_control`'s queued-command plus `Robot.tick(...)` model by owning tick/cache semantics internally. Reads should return coherent cached positions, velocities, and efforts for one cycle rather than independently ticking three times. Writes should queue commands and flush/update at a controlled point so the CAN bus is not double-loaded. Gravity compensation lives in `DMMotorArm` rather than a separate module. With `gravity_comp=True`, adapter position writes compute `tau_g(q)` from the current measured joint state and send MIT commands with gravity feed-forward. The adapter intentionally supports position and effort semantics only; velocity control is rejected because nonzero gains would hold the supplied `q` anchor. The adapter also exposes a gravity-compensation-only helper that sends MIT commands with `kp=0`, configurable low/no damping, and gravity torque for direct bring-up tests: ```text ControlCoordinator / caller -> DMMotorArm adapter - -> read current q/dq through dm_control tick/cache + -> read current q/dq through can_motor_control tick/cache -> compute tau_g(q) -> send MIT position command with adapter gains or effort/gravity-only kp=0 ``` @@ -60,8 +60,8 @@ No new DimOS `Spec` Protocol is required for this change unless implementation n - **Use the Python binding, not Rust crates directly.** This keeps the integration inside DimOS' Python module/adapter system and matches the user's requested surface. - **Create a new `dm_motor_arm` adapter instead of replacing `openarm`.** The current OpenArm adapter includes hardware-specific limits, MIT-mode setup, thermal reporting, and gravity compensation behavior. Replacing it would be a silent compatibility and safety change. -- **Keep `dm_control` dependency installation out of this change.** The adapter should clearly fail when selected and the Python package is unavailable, but DimOS packaging should not be changed yet. -- **Use lazy binding import.** Registry discovery should remain healthy even when `dm_control` is not installed. +- **Expose the binding through `dimos[manipulation]`.** The adapter should clearly fail when selected and the Python package is unavailable, while the manipulation extra should install the published `can-motor-control` package on supported platforms. +- **Use lazy binding import.** Registry discovery should remain healthy even when `can_motor_control` is not installed. - **Treat `Robot.tick(...)` as adapter-owned.** The adapter should ensure one coherent state snapshot per cycle and avoid ticking once per position/velocity/effort read. - **Provide gravity compensation in-place through the adapter.** This matches the existing OpenArm implementation and keeps lifecycle/tick ownership inside one hardware adapter. - **Use model-based gravity compensation with zero position stiffness for effort/gravity-only commands.** Gravity-only helper calls should send feed-forward torque and optional damping, not position targets with nonzero stiffness. @@ -71,7 +71,7 @@ No new DimOS `Spec` Protocol is required for this change unless implementation n Hardware assumptions: - DMMotor/Damiao hardware is connected through Linux SocketCAN with CAN-FD enabled by default, or a compatible mock/vcan backend supported by the Python binding. -- The Python binding is already installed in the active runtime environment. +- The Python binding is installed in the active runtime environment, typically through `dimos[manipulation]`. - Joint ordering in DimOS configuration matches the arm group ordering in the binding/config. - Gravity compensation model and hardware joint signs/offsets are valid before enabling gravity-only mode on real hardware. @@ -86,7 +86,7 @@ Safety constraints: Simulation/replay: - Replay is out of scope. -- Mock/vcan validation through `dm_control.MockCanBus` or SocketCAN virtual interfaces should be supported for development. +- Mock/vcan validation through `can_motor_control.MockCanBus` or SocketCAN virtual interfaces should be supported for development. - Existing `openarm` mock/planner blueprints remain available and unchanged. Manual QA surface: @@ -98,8 +98,7 @@ Manual QA surface: ## Risks / Trade-offs -- **Binding availability and API stability:** `dm_control` is a new binding. Mitigation: avoid package install changes, document expected availability, and keep adapter usage explicit. -- **Package-name collision:** `dm_control` is also a known Python package name in other ecosystems. Mitigation: document the expected binding source and verify import behavior in QA. +- **Binding availability and API stability:** `can-motor-control` is a new binding. Mitigation: document expected availability through `dimos[manipulation]`, verify import behavior in QA, and keep adapter usage explicit. - **Tick timing:** The binding flushes queued commands and receives feedback through `Robot.tick(...)`. Mitigation: centralize tick/cache behavior in the adapter and avoid multiple ticks per coordinator cycle. - **Gravity compensation correctness:** Incorrect model, joint signs, or offsets can push hardware unexpectedly. Mitigation: require mock/vcan and low-rate bring-up, document validation, and avoid position stiffness in gravity-only helper mode. diff --git a/openspec/changes/add-dm-motor-arm-adapter/docs.md b/openspec/changes/add-dm-motor-arm-adapter/docs.md index e91e532a9b..7e862cd122 100644 --- a/openspec/changes/add-dm-motor-arm-adapter/docs.md +++ b/openspec/changes/add-dm-motor-arm-adapter/docs.md @@ -2,9 +2,9 @@ - Update `docs/capabilities/manipulation/openarm_integration.md` to describe two OpenArm/DMMotor paths: - existing `openarm` adapter with in-tree custom CAN driver, - - new `dm_motor_arm` adapter using the `dm_control` Python binding when that binding is already installed. + - new `dm_motor_arm` adapter using the `can_motor_control` Python binding installed by `dimos[manipulation]` on supported platforms. - Update the OpenArm quick-start tables after blueprint names are finalized, including the opt-in DMMotor coordinator and the distinction from existing OpenArm trajectory/planner blueprints. -- Document that this change does not install `dm_control`; users must provide the Python binding in the active environment before selecting the new adapter. +- Document that `dimos[manipulation]` installs the published `can-motor-control` package on supported platforms, and selecting the adapter fails clearly if `can_motor_control` is not importable. - Document adapter-level gravity-compensation behavior: it computes gravity feed-forward in-place when enabled and can be disabled with `gravity_comp=False`. - Add hardware bring-up guidance for mock/vcan, one-motor validation, full-arm state monitor, gravity compensation, and safe shutdown. @@ -12,11 +12,11 @@ - Update contributor-facing manipulation hardware guidance if the adapter introduces a reusable pattern for lazy optional SDK imports or binding-backed adapters. - If new blueprint registry entries are added, mention the required generation command in implementation notes or relevant development docs: `pytest dimos/robot/test_all_blueprints_generation.py`. -- No broader development-process documentation is expected unless dependency packaging is added in a later change. +- No broader development-process documentation is expected beyond the manipulation extra dependency update. ## Coding-Agent Docs -- Update `docs/coding-agents/` only if there is an existing manipulation or hardware-adapter guide that should mention the `dm_control` binding path and gravity-compensation QA steps. +- Update `docs/coding-agents/` only if there is an existing manipulation or hardware-adapter guide that should mention the `can_motor_control` binding path and gravity-compensation QA steps. - No `AGENTS.md` update is required unless implementation reveals a new repo-wide convention. ## Doc Validation diff --git a/openspec/changes/add-dm-motor-arm-adapter/proposal.md b/openspec/changes/add-dm-motor-arm-adapter/proposal.md index 5c7bf0fe9d..b95e90de25 100644 --- a/openspec/changes/add-dm-motor-arm-adapter/proposal.md +++ b/openspec/changes/add-dm-motor-arm-adapter/proposal.md @@ -1,16 +1,16 @@ ## Why -DimOS already has OpenArm support, but the current adapter owns a custom Python CAN driver because there was no stable Python SDK surface for Damiao/OpenArm motors. The new `dm_control` Python binding provides a Rust-backed control library with a Python API for building robots, opening SocketCAN buses, ticking control loops, reading motor state, and commanding arm groups. Using the Python binding keeps DimOS integration in the existing Python manipulator adapter layer while avoiding a direct Rust integration in this change. +DimOS already has OpenArm support, but the current adapter owns a custom Python CAN driver because there was no stable Python SDK surface for Damiao/OpenArm motors. The `can-motor-control` Python package provides a Rust-backed control library with a Python API for building robots, opening SocketCAN buses, ticking control loops, reading motor state, and commanding arm groups. Using the Python binding keeps DimOS integration in the existing Python manipulator adapter layer while avoiding a direct Rust integration in this change. This change also needs built-in gravity compensation for DMMotor-based arms. The existing OpenArm adapter uses model-based gravity compensation inside MIT commands; the new adapter path should follow that pattern in-place through an adapter flag, applying feed-forward gravity torque without introducing a separate gravity-compensation module. ## What Changes -- Add a new DMMotor manipulator adapter behavior that uses the `dm_control` Python binding API rather than calling Rust crates directly. +- Add a new DMMotor manipulator adapter behavior that uses the `can_motor_control` Python binding API rather than calling Rust crates directly. - Add support for configuring DMMotor arm hardware through the existing manipulator adapter registry and ControlCoordinator hardware configuration path. - Add an adapter-level gravity-compensation operating path for DMMotor arms that sends gravity feed-forward torque while keeping the behavior opt-in/configurable through adapter kwargs. - Preserve existing `openarm` adapter behavior and blueprints unless explicitly migrated later; this change does not silently replace the current OpenArm custom CAN adapter. -- Do not add dependency installation or packaging changes yet; the adapter may document or expect the Python binding to already be available in the runtime environment. +- Add the published `can-motor-control` package to the manipulation extra so environments selecting the adapter can install it through `dimos[manipulation]`. - Mark hardware safety behavior explicitly: the adapter must stop or disable safely on shutdown and must avoid unintended stiff position-hold behavior in gravity-compensation-only commands. ## Affected DimOS Surfaces @@ -18,7 +18,7 @@ This change also needs built-in gravity compensation for DMMotor-based arms. The - Modules/streams: Manipulator adapter behavior behind `ManipulatorAdapter`; ControlCoordinator read/write behavior through existing `joint_state` and command routing surfaces. - Blueprints/CLI: New DMMotor/OpenArm-style hardware blueprint entry point for coordinator use through `dimos run` once registered. - Skills/MCP: No direct skill or MCP tool changes planned for this proposal. -- Hardware/simulation/replay: Real SocketCAN DMMotor/OpenArm hardware bring-up; mock/vcan validation through the `dm_control` Python binding where available; no replay changes planned. +- Hardware/simulation/replay: Real SocketCAN DMMotor/OpenArm hardware bring-up; mock/vcan validation through the `can_motor_control` Python binding where available; no replay changes planned. - Docs/generated registries: Manipulation/OpenArm documentation, blueprint registry generation if new runnable blueprints are added, and adapter registry discovery behavior. ## Capabilities @@ -35,6 +35,6 @@ This change also needs built-in gravity compensation for DMMotor-based arms. The ## Impact -Users gain a new path for DMMotor/OpenArm-style hardware that relies on the `dm_control` Python binding instead of maintaining another in-tree low-level CAN implementation. Developers can keep the integration inside the established Python adapter registry and ControlCoordinator flow, while future design work can decide when or whether existing OpenArm blueprints should migrate. +Users gain a new path for DMMotor/OpenArm-style hardware that relies on the `can-motor-control` Python package instead of maintaining another in-tree low-level CAN implementation. Developers can keep the integration inside the established Python adapter registry and ControlCoordinator flow, while future design work can decide when or whether existing OpenArm blueprints should migrate. -Compatibility risk is primarily around hardware safety, binding availability, joint ordering, tick timing, and gravity compensation semantics. No dependency installation should be introduced in this change yet, so environments that select the new adapter must already provide the `dm_control` Python package. Documentation and QA must cover mock/vcan validation, one-motor bring-up, full-arm state monitoring, adapter gravity-compensation behavior, and shutdown/disable behavior on interruption. +Compatibility risk is primarily around hardware safety, binding availability, joint ordering, tick timing, and gravity compensation semantics. The dependency is exposed through `dimos[manipulation]`, and selecting the adapter still fails explicitly if `can_motor_control` is not importable. Documentation and QA must cover mock/vcan validation, one-motor bring-up, full-arm state monitoring, adapter gravity-compensation behavior, and shutdown/disable behavior on interruption. diff --git a/openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md index a7c8db28dd..54c01b26b2 100644 --- a/openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md +++ b/openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md @@ -2,21 +2,21 @@ ### Requirement: DMMotor adapter selection -DimOS SHALL provide an opt-in DMMotor manipulator adapter path for DMMotor/Damiao arms that uses the `dm_control` Python binding as its hardware API surface. +DimOS SHALL provide an opt-in DMMotor manipulator adapter path for DMMotor/Damiao arms that uses the `can_motor_control` Python binding as its hardware API surface. #### Scenario: Selecting the DMMotor adapter - **GIVEN** a manipulator hardware configuration selects the DMMotor adapter type -- **AND** the `dm_control` Python binding is available in the runtime environment +- **AND** the `can_motor_control` Python binding is available in the runtime environment - **WHEN** the hardware is initialized - **THEN** DimOS SHALL create the DMMotor adapter through the manipulator adapter registry - **AND** the adapter SHALL use the Python binding rather than direct Rust crate calls. #### Scenario: Binding unavailable - **GIVEN** a manipulator hardware configuration selects the DMMotor adapter type -- **AND** the `dm_control` Python binding is not importable +- **AND** the `can_motor_control` Python binding is not importable - **WHEN** the hardware is initialized - **THEN** DimOS SHALL fail with an explicit error indicating that the Python binding is unavailable -- **AND** DimOS SHALL NOT attempt to install dependencies automatically. +- **AND** DimOS SHALL indicate that the binding is provided by the `dimos[manipulation]` extra on supported platforms. ### Requirement: DMMotor adapter lifecycle safety diff --git a/openspec/changes/add-dm-motor-arm-adapter/tasks.md b/openspec/changes/add-dm-motor-arm-adapter/tasks.md index dea5aab897..37ff224d01 100644 --- a/openspec/changes/add-dm-motor-arm-adapter/tasks.md +++ b/openspec/changes/add-dm-motor-arm-adapter/tasks.md @@ -1,10 +1,10 @@ ## 1. Adapter Implementation - [x] 1.1 Create `dimos/hardware/manipulators/dm_motor_arm/adapter.py` with `DMMotorArm` implementing `ManipulatorAdapter`, registered as `dm_motor_arm`. -- [x] 1.2 Add lazy `dm_control` Python binding import inside adapter construction/connect paths so registry discovery does not fail when the binding is absent. +- [x] 1.2 Add lazy `can_motor_control` Python binding import inside adapter construction/connect paths so registry discovery does not fail when the binding is absent. - [x] 1.3 Implement DMMotor lifecycle methods for connect, disconnect, enable, disable, clear error, state reads, and supported position/effort command writes through the Python binding. - [x] 1.4 Implement adapter-owned tick/cache behavior so one DimOS state-read cycle returns coherent position, velocity, and effort values without independently ticking per field. -- [x] 1.5 Implement binding-unavailable and lifecycle-error handling with explicit selected-adapter errors and no dependency installation attempts. +- [x] 1.5 Implement binding-unavailable and lifecycle-error handling with explicit selected-adapter errors when `can_motor_control` is unavailable. - [x] 1.6 Add configuration support for binding robot construction from an existing binding TOML path and/or DimOS adapter kwargs, preserving DimOS joint ordering and defaulting CAN-FD on through `canfd=True`. - [x] 1.7 Preserve existing `openarm` adapter registration and behavior; do not migrate current OpenArm blueprints unless they explicitly opt into `dm_motor_arm`. @@ -24,7 +24,7 @@ ## 4. Tests -- [x] 4.1 Add unit tests that adapter registry discovery includes `dm_motor_arm` without requiring top-level `dm_control` import success. +- [x] 4.1 Add unit tests that adapter registry discovery includes `dm_motor_arm` without requiring top-level `can_motor_control` import success. - [x] 4.2 Add missing-binding tests showing selected DMMotor adapter use fails with a clear message and does not auto-install dependencies. - [x] 4.3 Add adapter lifecycle tests with a fake or mocked binding robot covering connect, enable, read, write, disable, and disconnect order. - [x] 4.4 Add tick/cache tests proving one DimOS state-read cycle does not call the binding tick once per position, velocity, and effort read. @@ -34,7 +34,7 @@ ## 5. Documentation - [x] 5.1 Update `docs/capabilities/manipulation/openarm_integration.md` to document the existing `openarm` adapter and new `dm_motor_arm` Python-binding path separately. -- [x] 5.2 Document that this change does not install `dm_control`; users must provide the expected Python binding in the active environment. +- [x] 5.2 Document that `dimos[manipulation]` installs `can-motor-control` on supported platforms and that selected adapter use fails clearly if `can_motor_control` is absent. - [x] 5.3 Document adapter-level gravity compensation, upstream OpenArm ROS2 gain presets, and position/effort-only command semantics. - [x] 5.4 Document staged bring-up: mock/vcan, one-motor validation, full-arm state monitor, gravity compensation, then trajectory-control validation. - [x] 5.5 Update contributor or coding-agent docs only if implementation introduces a reusable lazy optional binding adapter pattern. diff --git a/openspec/changes/refactor-damiao-arm-adapters/design.md b/openspec/changes/refactor-damiao-arm-adapters/design.md index 7aef941840..490b703a05 100644 --- a/openspec/changes/refactor-damiao-arm-adapters/design.md +++ b/openspec/changes/refactor-damiao-arm-adapters/design.md @@ -22,7 +22,7 @@ Relevant surfaces include `dimos/hardware/manipulators/openarm/adapter.py`, `dim - Do not introduce a new runtime-configurable YAML/TOML sidecar schema for arbitrary users in this change. - Do not remove the existing `openarm` adapter registration. - Do not silently migrate all OpenArm blueprints to `dm_motor_arm`. -- Do not add dependency installation or package-management changes for `dm_control`. +- Do not build or vendor the Rust binding in DimOS; consume the published `can-motor-control` package through the manipulation extra. - Do not change the public ControlCoordinator stream contract or add new MCP/skill tools. - Do not implement ros2_control or transmission parsing. @@ -36,7 +36,7 @@ ControlCoordinator -> manipulator adapter registry -> OpenArmAdapter or DMMotorArm -> shared Damiao base behavior - -> dm_control binding or existing OpenArm CAN bus path, depending on adapter path + -> can_motor_control binding or existing OpenArm CAN bus path, depending on adapter path ``` The shared base should be an internal hardware-layer abstraction, not a new DimOS `Spec` Protocol. The public adapter contract remains `ManipulatorAdapter` from `dimos/hardware/manipulators/spec.py`. No new module streams, transports, or RPC injection contracts are required. diff --git a/pyproject.toml b/pyproject.toml index 6806733e06..bf05125c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -254,6 +254,7 @@ manipulation = [ "drake>=1.40.0; sys_platform != 'darwin' and platform_machine != 'aarch64'", # Hardware SDKs + "can-motor-control>=0.1.0; sys_platform == 'linux' and platform_machine == 'x86_64'", "piper-sdk", "pyrealsense2-extended; sys_platform != 'darwin'", "xarm-python-sdk>=1.17.0", @@ -445,7 +446,7 @@ tests-self-hosted = [ required-version = ">=0.9.17" default-groups = ["tests"] exclude-newer = "7 days" -exclude-newer-package = { dimos-viewer = false, pyrealsense2-extended = false, dimos-lcm = false, lcm-dimos-fork = false } +exclude-newer-package = { dimos-viewer = false, pyrealsense2-extended = false, dimos-lcm = false, lcm-dimos-fork = false, can-motor-control = false } override-dependencies = [ # moondream pins pillow<11 but we need >=12.2.0 for security fixes # (CVE-2026-25990, CVE-2026-40192, CVE-2026-42311). diff --git a/uv.lock b/uv.lock index 0752247aa5..7d46b8bd3b 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] +can-motor-control = false dimos-viewer = false dimos-lcm = false lcm-dimos-fork = false @@ -567,6 +568,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" }, ] +[[package]] +name = "can-motor-control" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/8d/b028431070f11f0b075d9b07f43f16aa134fe0bf50198d5d453f3695be7f/can_motor_control-0.1.0.tar.gz", hash = "sha256:780b2db0757721533f320c679fd90c0465aabfc4b8fd88c614ce2075778870c9", size = 114951, upload-time = "2026-06-06T04:11:04.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/80/921e6e630d23f7fb98d22a0ce211b4d42c0c30f2f9a5bac749898bab6565/can_motor_control-0.1.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:23fb8bd812d34cc306a43efabb484a1bb452c8d89ecf09299d9ce827e2e33234", size = 509924, upload-time = "2026-06-06T04:11:02.666Z" }, +] + [[package]] name = "catkin-pkg" version = "1.1.0" @@ -1908,6 +1922,7 @@ agents = [ all = [ { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "anthropic" }, + { name = "can-motor-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "catkin-pkg" }, { name = "cerebras-cloud-sdk" }, { name = "ctransformers" }, @@ -2040,6 +2055,7 @@ drone = [ ] manipulation = [ { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "can-motor-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "drake", version = "1.45.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, { name = "drake", version = "1.49.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, { name = "kaleido" }, @@ -2336,6 +2352,7 @@ requires-dist = [ { name = "annotation-protocol", specifier = ">=1.4.0" }, { name = "anthropic", marker = "extra == 'agents'", specifier = ">=0.19.0" }, { name = "bleak", specifier = ">=3.0.2" }, + { name = "can-motor-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'", specifier = ">=0.1.0" }, { name = "catkin-pkg", marker = "extra == 'misc'" }, { name = "cerebras-cloud-sdk", marker = "extra == 'misc'" }, { name = "colorlog", specifier = "==6.9.0" }, From 61aae76cacb4a83eacb494079802bc76a3752620 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:12:04 +0000 Subject: [PATCH 006/110] [autofix.ci] apply automated fixes --- .../current_position_hold_task.py | 4 +-- .../manipulators/damiao/base_adapter.py | 8 +++-- dimos/hardware/manipulators/damiao/specs.py | 1 + .../manipulators/dm_motor_arm/adapter.py | 34 ++++++++++++------- .../hardware/manipulators/openarm/adapter.py | 10 +++++- .../robot/manipulators/openarm/blueprints.py | 2 +- 6 files changed, 40 insertions(+), 19 deletions(-) diff --git a/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py b/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py index c508e1b7f3..264ae773a9 100644 --- a/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py +++ b/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py @@ -55,9 +55,7 @@ def __init__(self, name: str, config: CurrentPositionHoldTaskConfig) -> None: self._joint_names = frozenset(config.joint_names) self._joint_names_list = list(config.joint_names) self._active = False - logger.info( - f"CurrentPositionHoldTask {name} initialized for joints: {config.joint_names}" - ) + logger.info(f"CurrentPositionHoldTask {name} initialized for joints: {config.joint_names}") @property def name(self) -> str: diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index ae73e6b184..89e60840cb 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -67,8 +67,12 @@ def __init__( self._validate_length("kp", self._kp) self._validate_length("kd", self._kd) self._gravity_comp = gravity_comp - resolved_gravity_model = gravity_model_path if gravity_model_path is not None else arm_spec.gravity_model_path - self._gravity_model_path = str(resolved_gravity_model) if resolved_gravity_model is not None else None + resolved_gravity_model = ( + gravity_model_path if gravity_model_path is not None else arm_spec.gravity_model_path + ) + self._gravity_model_path = ( + str(resolved_gravity_model) if resolved_gravity_model is not None else None + ) resolved_torque_limits = ( gravity_torque_limits if gravity_torque_limits is not None diff --git a/dimos/hardware/manipulators/damiao/specs.py b/dimos/hardware/manipulators/damiao/specs.py index 88316ad24b..03630a8317 100644 --- a/dimos/hardware/manipulators/damiao/specs.py +++ b/dimos/hardware/manipulators/damiao/specs.py @@ -57,6 +57,7 @@ class DamiaoArmSpec: @property def dof(self) -> int: return len(self.motors) + @property def joint_names(self) -> tuple[str, ...]: return tuple(motor.name for motor in self.motors) diff --git a/dimos/hardware/manipulators/dm_motor_arm/adapter.py b/dimos/hardware/manipulators/dm_motor_arm/adapter.py index def83e8060..93ea4a75de 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/adapter.py +++ b/dimos/hardware/manipulators/dm_motor_arm/adapter.py @@ -120,9 +120,15 @@ def __init__( vendor="Damiao", model="DMMotorArm", motors=tuple(specs), - position_lower=position_lower if position_lower is not None else self._DEFAULT_POSITION_LOWER[:dof], - position_upper=position_upper if position_upper is not None else self._DEFAULT_POSITION_UPPER[:dof], - velocity_max=velocity_max if velocity_max is not None else self._DEFAULT_VELOCITY_MAX[:dof], + position_lower=position_lower + if position_lower is not None + else self._DEFAULT_POSITION_LOWER[:dof], + position_upper=position_upper + if position_upper is not None + else self._DEFAULT_POSITION_UPPER[:dof], + velocity_max=velocity_max + if velocity_max is not None + else self._DEFAULT_VELOCITY_MAX[:dof], kp=kp if kp is not None else self._DEFAULT_KP[:dof], kd=kd if kd is not None else self._DEFAULT_KD[:dof], gravity_model_path=gravity_model_path, @@ -240,7 +246,9 @@ def disconnect(self) -> None: try: self._robot.disable() except Exception as exc: - logger.warning(f"DMMotorArm {self._hardware_id} disable on disconnect failed: {exc}") + logger.warning( + f"DMMotorArm {self._hardware_id} disable on disconnect failed: {exc}" + ) self._enabled = False self._connected = False self._robot = None @@ -250,9 +258,7 @@ def disconnect(self) -> None: def is_connected(self) -> bool: return self._connected - def refresh_state( - self, *, force: bool = False - ) -> tuple[list[float], list[float], list[float]]: + def refresh_state(self, *, force: bool = False) -> tuple[list[float], list[float], list[float]]: if self._robot is None or self._arm is None: raise RuntimeError("DMMotorArm is not connected") now = time.monotonic() @@ -333,7 +339,11 @@ def write_joint_torques(self, efforts: list[float]) -> bool: return False if len(efforts) != self._dof: return False - q = self._last_positions if self._last_positions is not None else self.read_joint_positions() + q = ( + self._last_positions + if self._last_positions is not None + else self.read_joint_positions() + ) return self.write_mit_commands( q=q, dq=self._zero_vector(), @@ -350,9 +360,7 @@ def write_gravity_compensation(self, damping: float | list[float] = 0.0) -> bool logger.warning(f"Skipping DMMotor gravity compensation due to invalid state: {exc}") return False kd = [float(damping)] * self._dof if isinstance(damping, int | float) else list(damping) - return self.write_mit_commands( - q=q, dq=dq, kp=self._zero_vector(), kd=kd, tau=tau - ) + return self.write_mit_commands(q=q, dq=dq, kp=self._zero_vector(), kd=kd, tau=tau) def write_mit_commands( self, @@ -366,7 +374,9 @@ def write_mit_commands( if self._arm is None or self._robot is None or not self._enabled: return False rows = self._mit_command_rows(q=q, dq=dq, kp=kp, kd=kd, tau=tau) - cmds = np.array([(row[2], row[3], row[0], row[1], row[4]) for row in rows], dtype=np.float64) + cmds = np.array( + [(row[2], row[3], row[0], row[1], row[4]) for row in rows], dtype=np.float64 + ) self._arm.mit_control(cmds) self._robot.tick(self._tick_deadline_us) self._state_cache = None diff --git a/dimos/hardware/manipulators/openarm/adapter.py b/dimos/hardware/manipulators/openarm/adapter.py index 6dbe37775e..68e5882797 100644 --- a/dimos/hardware/manipulators/openarm/adapter.py +++ b/dimos/hardware/manipulators/openarm/adapter.py @@ -63,7 +63,15 @@ class OpenArmAdapter(DamiaoArmAdapterBase): _V10_POS_UPPER_LEFT: tuple[float, ...] = (1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50) _V10_POS_LOWER_RIGHT: tuple[float, ...] = (-1.35, -0.15, -1.50, -0.01, -1.50, -0.75, -1.50) _V10_POS_UPPER_RIGHT: tuple[float, ...] = (3.45, 3.30, 1.50, 2.40, 1.50, 0.75, 1.50) - _V10_VEL_MAX: tuple[float, ...] = (16.754666, 16.754666, 5.445426, 5.445426, 20.943946, 20.943946, 20.943946) + _V10_VEL_MAX: tuple[float, ...] = ( + 16.754666, + 16.754666, + 5.445426, + 5.445426, + 20.943946, + 20.943946, + 20.943946, + ) _DEFAULT_KP: tuple[float, ...] = (100.0, 100.0, 80.0, 80.0, 60.0, 60.0, 60.0) _DEFAULT_KD: tuple[float, ...] = (1.5, 1.5, 1.0, 1.0, 0.8, 0.8, 0.8) _STATE_MAX_AGE_S = 0.1 diff --git a/dimos/robot/manipulators/openarm/blueprints.py b/dimos/robot/manipulators/openarm/blueprints.py index 4f30ecf1d6..22ebe67d87 100644 --- a/dimos/robot/manipulators/openarm/blueprints.py +++ b/dimos/robot/manipulators/openarm/blueprints.py @@ -251,8 +251,8 @@ __all__ = [ - "coordinator_openarm_bimanual", "coordinator_dm_motor_openarm", + "coordinator_openarm_bimanual", "coordinator_openarm_left", "coordinator_openarm_mock", "coordinator_openarm_right", From a0ed6709a0b6f5be858abb84d3156b7901e1b057 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 14:35:20 -0700 Subject: [PATCH 007/110] fix: refresh dm motor state reads --- .../manipulators/dm_motor_arm/adapter.py | 1 + .../manipulators/dm_motor_arm/test_adapter.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/dimos/hardware/manipulators/dm_motor_arm/adapter.py b/dimos/hardware/manipulators/dm_motor_arm/adapter.py index 93ea4a75de..57da734b89 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/adapter.py +++ b/dimos/hardware/manipulators/dm_motor_arm/adapter.py @@ -268,6 +268,7 @@ def refresh_state(self, *, force: bool = False) -> tuple[list[float], list[float and now - self._state_cache_time <= self._state_cache_ttl_s ): return self._state_cache + self._arm.refresh() self._robot.tick(self._tick_deadline_us) state = ( self._arm.positions().astype(float).tolist(), diff --git a/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py b/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py index 2999cc9a24..f9df8421ee 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py +++ b/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py @@ -66,6 +66,7 @@ def __init__(self, dof: int) -> None: self.mit_commands: list[np.ndarray] = [] self.position_commands: list[np.ndarray] = [] self.velocity_commands: list[np.ndarray] = [] + self.refresh_count = 0 self.motors = {f"joint{i + 1}": FakeMotor() for i in range(dof)} def __len__(self) -> int: @@ -83,6 +84,9 @@ def velocities(self) -> np.ndarray: def torques(self) -> np.ndarray: return self.torques_value + def refresh(self) -> None: + self.refresh_count += 1 + def mit_control(self, cmds: np.ndarray) -> None: self.mit_commands.append(cmds.copy()) @@ -299,12 +303,29 @@ def test_state_reads_share_one_tick() -> None: assert adapter.connect() is True robot = FakeRobot.last assert robot is not None + robot.arm.refresh_count = 0 robot.tick_count = 0 adapter._state_cache = None adapter.read_joint_positions() adapter.read_joint_velocities() adapter.read_joint_efforts() assert robot.tick_count == 1 + assert robot.arm.refresh_count == 1 + + +def test_uncached_state_read_queues_no_motion_refresh() -> None: + adapter = DMMotorArm(use_mock_bus=True, state_cache_ttl_s=0.0) + assert adapter.connect() is True + robot = FakeRobot.last + assert robot is not None + + robot.arm.refresh_count = 0 + robot.tick_count = 0 + assert adapter.read_joint_positions() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + + assert robot.arm.refresh_count == 1 + assert robot.tick_count == 1 + assert robot.arm.mit_commands == [] def test_default_gains_match_openarm_ros2_presets() -> None: From 2fa597a81ff8b9cdeeeee77d567b9b61f3538cf3 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 16:10:12 -0700 Subject: [PATCH 008/110] refactor: scope openarm rs adapter --- dimos/hardware/manipulators/damiao/specs.py | 12 + .../hardware/manipulators/openarm/adapter.py | 216 ++++++++++++------ .../manipulators/openarm/test_adapter.py | 6 +- .../{dm_motor_arm => openarm_rs}/__init__.py | 4 +- .../{dm_motor_arm => openarm_rs}/adapter.py | 98 ++++---- .../test_adapter.py | 102 ++++----- dimos/robot/all_blueprints.py | 4 +- .../robot/manipulators/openarm/blueprints.py | 30 +-- .../manipulation/openarm_integration.md | 26 +-- .../.openspec.yaml | 0 .../design.md | 0 .../docs.md | 0 .../proposal.md | 0 .../dm-motor-manipulator-adapters/spec.md | 0 .../gravity-compensation-control/spec.md | 0 .../specs/manipulation-stack/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 2 + .../design.md | 122 ++++++++++ .../docs.md | 28 +++ .../proposal.md | 40 ++++ .../damiao-adapter-metadata-docs/spec.md | 27 +++ .../dm-motor-manipulator-adapters/spec.md | 39 ++++ .../openarm-adapter-compatibility/spec.md | 39 ++++ .../openarm-rs-adapter-selection/spec.md | 37 +++ .../tasks.md | 35 +++ .../.openspec.yaml | 0 .../design.md | 0 .../docs.md | 0 .../proposal.md | 0 .../specs/damiao-arm-adapter-refactor/spec.md | 0 .../openarm-adapter-compatibility/spec.md | 0 .../tasks.md | 0 .../damiao-adapter-metadata-docs/spec.md | 31 +++ .../dm-motor-manipulator-adapters/spec.md | 43 ++++ .../openarm-adapter-compatibility/spec.md | 43 ++++ .../openarm-rs-adapter-selection/spec.md | 41 ++++ 37 files changed, 817 insertions(+), 208 deletions(-) rename dimos/hardware/manipulators/{dm_motor_arm => openarm_rs}/__init__.py (84%) rename dimos/hardware/manipulators/{dm_motor_arm => openarm_rs}/adapter.py (85%) rename dimos/hardware/manipulators/{dm_motor_arm => openarm_rs}/test_adapter.py (83%) rename openspec/changes/{add-dm-motor-arm-adapter => archive/2026-06-06-add-dm-motor-arm-adapter}/.openspec.yaml (100%) rename openspec/changes/{add-dm-motor-arm-adapter => archive/2026-06-06-add-dm-motor-arm-adapter}/design.md (100%) rename openspec/changes/{add-dm-motor-arm-adapter => archive/2026-06-06-add-dm-motor-arm-adapter}/docs.md (100%) rename openspec/changes/{add-dm-motor-arm-adapter => archive/2026-06-06-add-dm-motor-arm-adapter}/proposal.md (100%) rename openspec/changes/{add-dm-motor-arm-adapter => archive/2026-06-06-add-dm-motor-arm-adapter}/specs/dm-motor-manipulator-adapters/spec.md (100%) rename openspec/changes/{add-dm-motor-arm-adapter => archive/2026-06-06-add-dm-motor-arm-adapter}/specs/gravity-compensation-control/spec.md (100%) rename openspec/changes/{add-dm-motor-arm-adapter => archive/2026-06-06-add-dm-motor-arm-adapter}/specs/manipulation-stack/spec.md (100%) rename openspec/changes/{add-dm-motor-arm-adapter => archive/2026-06-06-add-dm-motor-arm-adapter}/tasks.md (100%) create mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/design.md create mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/docs.md create mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/proposal.md create mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/damiao-adapter-metadata-docs/spec.md create mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/dm-motor-manipulator-adapters/spec.md create mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-adapter-compatibility/spec.md create mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-rs-adapter-selection/spec.md create mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/tasks.md rename openspec/changes/{refactor-damiao-arm-adapters => archive/2026-06-06-refactor-damiao-arm-adapters}/.openspec.yaml (100%) rename openspec/changes/{refactor-damiao-arm-adapters => archive/2026-06-06-refactor-damiao-arm-adapters}/design.md (100%) rename openspec/changes/{refactor-damiao-arm-adapters => archive/2026-06-06-refactor-damiao-arm-adapters}/docs.md (100%) rename openspec/changes/{refactor-damiao-arm-adapters => archive/2026-06-06-refactor-damiao-arm-adapters}/proposal.md (100%) rename openspec/changes/{refactor-damiao-arm-adapters => archive/2026-06-06-refactor-damiao-arm-adapters}/specs/damiao-arm-adapter-refactor/spec.md (100%) rename openspec/changes/{refactor-damiao-arm-adapters => archive/2026-06-06-refactor-damiao-arm-adapters}/specs/openarm-adapter-compatibility/spec.md (100%) rename openspec/changes/{refactor-damiao-arm-adapters => archive/2026-06-06-refactor-damiao-arm-adapters}/tasks.md (100%) create mode 100644 openspec/specs/damiao-adapter-metadata-docs/spec.md create mode 100644 openspec/specs/dm-motor-manipulator-adapters/spec.md create mode 100644 openspec/specs/openarm-adapter-compatibility/spec.md create mode 100644 openspec/specs/openarm-rs-adapter-selection/spec.md diff --git a/dimos/hardware/manipulators/damiao/specs.py b/dimos/hardware/manipulators/damiao/specs.py index 03630a8317..af3363a367 100644 --- a/dimos/hardware/manipulators/damiao/specs.py +++ b/dimos/hardware/manipulators/damiao/specs.py @@ -30,6 +30,8 @@ class DamiaoMotorSpec: @property def effective_recv_id(self) -> int: + """Return the explicit receive CAN ID, or Damiao's default response ID.""" + return self.recv_id if self.recv_id is not None else (self.send_id | 0x10) @@ -56,10 +58,14 @@ class DamiaoArmSpec: @property def dof(self) -> int: + """Return the number of joints described by this arm spec.""" + return len(self.motors) @property def joint_names(self) -> tuple[str, ...]: + """Return joint names in adapter and command-vector order.""" + return tuple(motor.name for motor in self.motors) @classmethod @@ -83,6 +89,8 @@ def from_values( fd: bool = False, supports_velocity: bool = False, ) -> DamiaoArmSpec: + """Build a typed arm spec from list/tuple metadata values.""" + return cls( name=name, vendor=vendor, @@ -107,6 +115,8 @@ def from_values( ) def validate(self) -> None: + """Validate CAN ID uniqueness and per-joint metadata lengths.""" + if not self.motors: raise ValueError("DamiaoArmSpec requires at least one motor") send_ids = [motor.send_id for motor in self.motors] @@ -132,6 +142,8 @@ def coerce_motor_specs( motor_specs: Sequence[Mapping[str, object] | DamiaoMotorSpec], dof: int, ) -> tuple[DamiaoMotorSpec, ...]: + """Normalize mapping or dataclass motor metadata into typed motor specs.""" + specs: list[DamiaoMotorSpec] = [] for spec in motor_specs: if isinstance(spec, DamiaoMotorSpec): diff --git a/dimos/hardware/manipulators/openarm/adapter.py b/dimos/hardware/manipulators/openarm/adapter.py index 68e5882797..70554817e4 100644 --- a/dimos/hardware/manipulators/openarm/adapter.py +++ b/dimos/hardware/manipulators/openarm/adapter.py @@ -18,10 +18,10 @@ from pathlib import Path import time -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any + +import numpy as np -from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase -from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec from dimos.hardware.manipulators.openarm.driver import ( CTRL_MODE_MIT, DamiaoMotor, @@ -30,6 +30,8 @@ ) from dimos.hardware.manipulators.spec import ( ControlMode, + JointLimits, + ManipulatorInfo, ) from dimos.utils.data import LfsPath @@ -47,34 +49,43 @@ def _socketcan_iface_up(name: str) -> bool: return False -class OpenArmAdapter(DamiaoArmAdapterBase): +# OpenArm v10 BOM — (send_id, MotorType) per joint, derived from the torque +# column of data/openarm_description/config/arm/v10/joint_limits.yaml. +_OPENARM_V10_ARM_MOTORS: list[tuple[int, MotorType]] = [ + (0x01, MotorType.DM8006), # joint1 + (0x02, MotorType.DM8006), # joint2 + (0x03, MotorType.DM4340), # joint3 + (0x04, MotorType.DM4340), # joint4 + (0x05, MotorType.DM4310), # joint5 + (0x06, MotorType.DM4310), # joint6 + (0x07, MotorType.DM4310), # joint7 +] +# Gripper (motor id 0x08, DM4310) is on the bus but not currently wired up +# through the adapter — see the gripper-write methods which return None/False. + +# Physical joint limits (measured). Joints 1 & 2 are mirrored between sides. +_V10_POS_LOWER_LEFT = [-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50] +_V10_POS_UPPER_LEFT = [1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50] +_V10_POS_LOWER_RIGHT = [-1.35, -0.15, -1.50, -0.01, -1.50, -0.75, -1.50] +_V10_POS_UPPER_RIGHT = [3.45, 3.30, 1.50, 2.40, 1.50, 0.75, 1.50] +_V10_VEL_MAX = [16.754666, 16.754666, 5.445426, 5.445426, 20.943946, 20.943946, 20.943946] + +# Default MIT gains per joint for POSITION mode. +# kp range is [0, 500], kd range is [0, 5]. +# With gravity compensation enabled, the PD gains only handle transient +# tracking — they don't fight gravity. Lower kp = smoother, less buzz. +# High kd causes high-frequency buzz/grinding from the gearbox. +_DEFAULT_KP = [100.0, 100.0, 80.0, 80.0, 60.0, 60.0, 60.0] +_DEFAULT_KD = [1.5, 1.5, 1.0, 1.0, 0.8, 0.8, 0.8] +_STATE_MAX_AGE_S = 0.1 + + +class OpenArmAdapter: + """7-DOF OpenArm on one SocketCAN bus. side=left|right picks URDF + limits.""" + + # Per-side URDFs for Pinocchio gravity model (LFS-backed) _URDF_LEFT = LfsPath("openarm_description/urdf/robot/openarm_v10_left.urdf") _URDF_RIGHT = LfsPath("openarm_description/urdf/robot/openarm_v10_right.urdf") - _V10_ARM_MOTORS: tuple[DamiaoMotorSpec, ...] = ( - DamiaoMotorSpec("joint1", MotorType.DM8006, 0x01, 0x11), - DamiaoMotorSpec("joint2", MotorType.DM8006, 0x02, 0x12), - DamiaoMotorSpec("joint3", MotorType.DM4340, 0x03, 0x13), - DamiaoMotorSpec("joint4", MotorType.DM4340, 0x04, 0x14), - DamiaoMotorSpec("joint5", MotorType.DM4310, 0x05, 0x15), - DamiaoMotorSpec("joint6", MotorType.DM4310, 0x06, 0x16), - DamiaoMotorSpec("joint7", MotorType.DM4310, 0x07, 0x17), - ) - _V10_POS_LOWER_LEFT: tuple[float, ...] = (-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50) - _V10_POS_UPPER_LEFT: tuple[float, ...] = (1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50) - _V10_POS_LOWER_RIGHT: tuple[float, ...] = (-1.35, -0.15, -1.50, -0.01, -1.50, -0.75, -1.50) - _V10_POS_UPPER_RIGHT: tuple[float, ...] = (3.45, 3.30, 1.50, 2.40, 1.50, 0.75, 1.50) - _V10_VEL_MAX: tuple[float, ...] = ( - 16.754666, - 16.754666, - 5.445426, - 5.445426, - 20.943946, - 20.943946, - 20.943946, - ) - _DEFAULT_KP: tuple[float, ...] = (100.0, 100.0, 80.0, 80.0, 60.0, 60.0, 60.0) - _DEFAULT_KD: tuple[float, ...] = (1.5, 1.5, 1.0, 1.0, 0.8, 0.8, 0.8) - _STATE_MAX_AGE_S = 0.1 def __init__( self, @@ -90,54 +101,33 @@ def __init__( auto_set_mit_mode: bool = True, **_: Any, ) -> None: + if dof != 7: + raise ValueError(f"OpenArmAdapter only supports 7 DOF (got {dof})") if side not in ("left", "right"): raise ValueError(f"side must be 'left' or 'right', got {side!r}") - position_lower = self._V10_POS_LOWER_LEFT if side == "left" else self._V10_POS_LOWER_RIGHT - position_upper = self._V10_POS_UPPER_LEFT if side == "left" else self._V10_POS_UPPER_RIGHT - urdf = self._URDF_LEFT if side == "left" else self._URDF_RIGHT - arm_spec = DamiaoArmSpec( - name=f"openarm_{side}", - vendor="Enactic", - model=f"OpenArm v10 ({side})", - motors=self._V10_ARM_MOTORS, - position_lower=position_lower, - position_upper=position_upper, - velocity_max=self._V10_VEL_MAX, - kp=self._DEFAULT_KP, - kd=self._DEFAULT_KD, - gravity_model_path=urdf, - gravity_torque_limits=tuple( - DamiaoMotor(motor.send_id, cast("MotorType", motor.type), motor.recv_id).limits[2] - for motor in self._V10_ARM_MOTORS - ), - fd=fd, - supports_velocity=True, - ) - super().__init__( - arm_spec=arm_spec, - dof=dof, - kp=kp, - kd=kd, - gravity_comp=gravity_comp, - supported_control_modes=( - ControlMode.POSITION, - ControlMode.SERVO_POSITION, - ControlMode.VELOCITY, - ControlMode.TORQUE, - ), - ) self._address = address + self._dof = dof self._side = side self._fd = fd self._interface = interface + self._kp = list(kp) if kp is not None else list(_DEFAULT_KP) + self._kd = list(kd) if kd is not None else list(_DEFAULT_KD) + if len(self._kp) != dof or len(self._kd) != dof: + raise ValueError("kp/kd must be length 7") + self._gravity_comp = gravity_comp self._auto_set_mit_mode = auto_set_mit_mode - self._motors = [ - DamiaoMotor(spec.send_id, cast("MotorType", spec.type), spec.recv_id) - for spec in self._motor_specs - ] + + self._motors = [DamiaoMotor(sid, mt) for sid, mt in _OPENARM_V10_ARM_MOTORS] self._bus: OpenArmBus | None = None + self._control_mode: ControlMode = ControlMode.POSITION + self._enabled: bool = False + # Last successful position command — used as q_target for VELOCITY mode self._last_cmd_q: list[float] | None = None + # Pinocchio model for gravity compensation (loaded lazily in connect()) + self._pin_model: Any = None + self._pin_data: Any = None + def connect(self) -> bool: # Preflight: verify the SocketCAN interface is up before opening the bus. # Bringing the interface up requires root privileges, so we don't do it @@ -181,13 +171,17 @@ def connect(self) -> bool: "auto_set_mit_mode disabled — relying on persisted register" ) + # Load Pinocchio model for gravity compensation if self._gravity_comp: try: - self._load_gravity_model() - if self._pin_model is not None: - print( - f"OpenArm {self._side}: gravity compensation enabled (nq={self._pin_model.nq})" - ) + import pinocchio + + urdf = str(self._URDF_LEFT if self._side == "left" else self._URDF_RIGHT) + self._pin_model = pinocchio.buildModelFromUrdf(urdf) + self._pin_data = self._pin_model.createData() + print( + f"OpenArm {self._side}: gravity compensation enabled (nq={self._pin_model.nq})" + ) except Exception as e: print(f"WARNING: gravity comp disabled — {e}") self._pin_model = None @@ -209,6 +203,46 @@ def disconnect(self) -> None: def is_connected(self) -> bool: return self._bus is not None + def get_info(self) -> ManipulatorInfo: + return ManipulatorInfo( + vendor="Enactic", + model=f"OpenArm v10 ({self._side})", + dof=self._dof, + firmware_version=None, + serial_number=None, + ) + + def get_dof(self) -> int: + return self._dof + + def get_limits(self) -> JointLimits: + if self._side == "left": + lower, upper = _V10_POS_LOWER_LEFT, _V10_POS_UPPER_LEFT + else: + lower, upper = _V10_POS_LOWER_RIGHT, _V10_POS_UPPER_RIGHT + return JointLimits( + position_lower=list(lower), + position_upper=list(upper), + velocity_max=list(_V10_VEL_MAX), + ) + + def set_control_mode(self, mode: ControlMode) -> bool: + # OpenArm runs exclusively in Damiao MIT register mode; we emulate + # dimos ControlModes by tuning kp/kd/q/dq/tau on each MIT frame. + # Cartesian/impedance control are outside this adapter's scope. + if mode in ( + ControlMode.POSITION, + ControlMode.SERVO_POSITION, + ControlMode.VELOCITY, + ControlMode.TORQUE, + ): + self._control_mode = mode + return True + return False + + def get_control_mode(self) -> ControlMode: + return self._control_mode + def _states_or_raise(self) -> list[Any]: # Raises on missing or stale data so hardware_interface.py can retry # (init) or skip the tick (steady-state). @@ -219,7 +253,7 @@ def _states_or_raise(self) -> list[Any]: for i, s in enumerate(states): if s is None: raise RuntimeError(f"motor {i + 1} has no state yet") - if now - s.timestamp > self._STATE_MAX_AGE_S: + if now - s.timestamp > _STATE_MAX_AGE_S: age_ms = (now - s.timestamp) * 1000 raise RuntimeError(f"motor {i + 1} state stale ({age_ms:.0f} ms)") return states @@ -258,6 +292,18 @@ def read_error(self) -> tuple[int, str]: return 1, f"rotor over-temperature ({t_rotor}°C)" return 0, "" + def _compute_gravity_torques(self, q: list[float]) -> list[float]: + # Pinocchio G(q), clamped to motor torque limits. + if self._pin_model is None or self._pin_data is None: + return [0.0] * self._dof + import pinocchio + + q_arr = np.array(q, dtype=np.float64) + tau_g = pinocchio.computeGeneralizedGravity(self._pin_model, self._pin_data, q_arr) + # Clamp to motor torque limits for safety + limits = [m.limits for m in self._motors] # (p_max, v_max, t_max) + return [float(np.clip(tau_g[i], -lim[2], lim[2])) for i, lim in enumerate(limits)] + def write_joint_positions( self, positions: list[float], @@ -274,7 +320,7 @@ def write_joint_positions( # back to commanded q with no feedforward instead of crashing. try: q_current = self.read_joint_positions() - tau_ff = self.compute_gravity_torques(q_current) + tau_ff = self._compute_gravity_torques(q_current) except RuntimeError: tau_ff = [0.0] * self._dof commands = [ @@ -304,7 +350,7 @@ def write_joint_velocities(self, velocities: list[float]) -> bool: anchor = self._last_cmd_q try: q_current = self.read_joint_positions() - tau_ff = self.compute_gravity_torques(q_current) + tau_ff = self._compute_gravity_torques(q_current) except RuntimeError: tau_ff = [0.0] * self._dof commands = [ @@ -323,7 +369,7 @@ def write_stop(self) -> bool: q_now = self.read_joint_positions() except RuntimeError: return False - tau_ff = self.compute_gravity_torques(q_now) + tau_ff = self._compute_gravity_torques(q_now) commands = [ (q, 0.0, kp, kd, tau) for q, kp, kd, tau in zip(q_now, self._kp, self._kd, tau_ff, strict=False) @@ -346,6 +392,9 @@ def write_enable(self, enable: bool) -> bool: self._enabled = enable return True + def read_enabled(self) -> bool: + return self._enabled + def write_clear_errors(self) -> bool: # Damiao motors have no separate clear-error command; re-enabling # after a fault is the recovery path. @@ -360,6 +409,21 @@ def write_clear_errors(self) -> bool: self._enabled = True return True + def read_cartesian_position(self) -> dict[str, float] | None: + return None + + def write_cartesian_position(self, pose: dict[str, float], velocity: float = 1.0) -> bool: + return False + + def read_gripper_position(self) -> float | None: + return None + + def write_gripper_position(self, position: float) -> bool: + return False + + def read_force_torque(self) -> list[float] | None: + return None + # ── Registry hook (required for auto-discovery) ─────────────────── def register(registry: AdapterRegistry) -> None: diff --git a/dimos/hardware/manipulators/openarm/test_adapter.py b/dimos/hardware/manipulators/openarm/test_adapter.py index f70a2c2f91..2f15ef1ee1 100644 --- a/dimos/hardware/manipulators/openarm/test_adapter.py +++ b/dimos/hardware/manipulators/openarm/test_adapter.py @@ -18,7 +18,6 @@ import pytest -from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase from dimos.hardware.manipulators.openarm.adapter import OpenArmAdapter, register from dimos.hardware.manipulators.spec import ControlMode, ManipulatorAdapter @@ -74,7 +73,6 @@ def disable_all(self) -> None: def test_implements_manipulator_adapter() -> None: assert isinstance(OpenArmAdapter(gravity_comp=False), ManipulatorAdapter) - assert isinstance(OpenArmAdapter(gravity_comp=False), DamiaoArmAdapterBase) def test_register_preserves_openarm_key() -> None: @@ -88,9 +86,9 @@ def test_constructor_validates_dof_side_and_gain_lengths() -> None: OpenArmAdapter(dof=6, gravity_comp=False) with pytest.raises(ValueError, match="side must be 'left' or 'right'"): OpenArmAdapter(side="middle", gravity_comp=False) - with pytest.raises(ValueError, match="kp length 1 does not match dof 7"): + with pytest.raises(ValueError, match="kp/kd must be length 7"): OpenArmAdapter(kp=[1.0], gravity_comp=False) - with pytest.raises(ValueError, match="kd length 1 does not match dof 7"): + with pytest.raises(ValueError, match="kp/kd must be length 7"): OpenArmAdapter(kd=[1.0], gravity_comp=False) diff --git a/dimos/hardware/manipulators/dm_motor_arm/__init__.py b/dimos/hardware/manipulators/openarm_rs/__init__.py similarity index 84% rename from dimos/hardware/manipulators/dm_motor_arm/__init__.py rename to dimos/hardware/manipulators/openarm_rs/__init__.py index 5306d4a602..700c75b188 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/__init__.py +++ b/dimos/hardware/manipulators/openarm_rs/__init__.py @@ -13,6 +13,6 @@ # limitations under the License. -from dimos.hardware.manipulators.dm_motor_arm.adapter import DMMotorArm +from dimos.hardware.manipulators.openarm_rs.adapter import OpenArmRSAdapter -__all__ = ["DMMotorArm"] +__all__ = ["OpenArmRSAdapter"] diff --git a/dimos/hardware/manipulators/dm_motor_arm/adapter.py b/dimos/hardware/manipulators/openarm_rs/adapter.py similarity index 85% rename from dimos/hardware/manipulators/dm_motor_arm/adapter.py rename to dimos/hardware/manipulators/openarm_rs/adapter.py index 57da734b89..9c19041a78 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/adapter.py @@ -32,10 +32,10 @@ logger = setup_logger() -DMMotorSpecConfig = DamiaoMotorSpec +OpenArmRSMotorSpecConfig = DamiaoMotorSpec -class DMMotorBindingUnavailableError(RuntimeError): +class OpenArmRSBindingUnavailableError(RuntimeError): pass @@ -44,10 +44,10 @@ def _load_can_motor_control() -> tuple[ModuleType, ModuleType]: can_motor_control = importlib.import_module("can_motor_control") damiao = importlib.import_module("can_motor_control.damiao") except ImportError as exc: - raise DMMotorBindingUnavailableError( - "The selected 'dm_motor_arm' adapter requires the Rust-backed " + raise OpenArmRSBindingUnavailableError( + "The selected 'openarm_rs' adapter requires the Rust-backed " "can-motor-control Python binding in the active environment. Install " - "dimos[manipulation] before selecting adapter_type='dm_motor_arm'." + "dimos[manipulation] before selecting adapter_type='openarm_rs'." ) from exc return can_motor_control, damiao @@ -73,7 +73,7 @@ def _resolve_motor_type(damiao: ModuleType, motor_type: str | int | Any) -> Any: raise ValueError(f"Unknown Damiao motor type value {motor_type!r}") -class DMMotorArm(DamiaoArmAdapterBase): +class OpenArmRSAdapter(DamiaoArmAdapterBase): _DEFAULT_OPENARM_MOTORS: tuple[DamiaoMotorSpec, ...] = ( DamiaoMotorSpec("joint1", "DM8006", 0x01, 0x11), DamiaoMotorSpec("joint2", "DM8006", 0x02, 0x12), @@ -114,21 +114,21 @@ def __init__( gravity_torque_limits: list[float] | None = None, **_: Any, ) -> None: - specs = self._coerce_motor_specs(motor_specs, dof) + specs = self._openarm_motor_specs( + dof=dof, + motor_specs=motor_specs, + position_lower=position_lower, + position_upper=position_upper, + velocity_max=velocity_max, + ) arm_spec = DamiaoArmSpec.from_values( - name="dm_motor_arm", - vendor="Damiao", - model="DMMotorArm", + name="openarm_rs", + vendor="Enactic", + model="OpenArm RS v10", motors=tuple(specs), - position_lower=position_lower - if position_lower is not None - else self._DEFAULT_POSITION_LOWER[:dof], - position_upper=position_upper - if position_upper is not None - else self._DEFAULT_POSITION_UPPER[:dof], - velocity_max=velocity_max - if velocity_max is not None - else self._DEFAULT_VELOCITY_MAX[:dof], + position_lower=self._DEFAULT_POSITION_LOWER, + position_upper=self._DEFAULT_POSITION_UPPER, + velocity_max=self._DEFAULT_VELOCITY_MAX, kp=kp if kp is not None else self._DEFAULT_KP[:dof], kd=kd if kd is not None else self._DEFAULT_KD[:dof], gravity_model_path=gravity_model_path, @@ -165,27 +165,24 @@ def __init__( self._state_cache_time = 0.0 @classmethod - def _coerce_motor_specs( + def _openarm_motor_specs( cls, - motor_specs: list[dict[str, Any] | DamiaoMotorSpec] | None, + *, dof: int, + motor_specs: list[dict[str, Any] | DamiaoMotorSpec] | None, + position_lower: list[float] | None, + position_upper: list[float] | None, + velocity_max: list[float] | None, ) -> list[DamiaoMotorSpec]: - if motor_specs is None: - if dof != len(cls._DEFAULT_OPENARM_MOTORS): - raise ValueError( - "motor_specs is required when constructing a dm_motor_arm with " - f"{dof} DOF; the built-in default only describes OpenArm-style 7 DOF" - ) - return list(cls._DEFAULT_OPENARM_MOTORS) - specs: list[DamiaoMotorSpec] = [] - for spec in motor_specs: - if isinstance(spec, DamiaoMotorSpec): - specs.append(spec) - else: - specs.append(DamiaoMotorSpec(**spec)) - if len(specs) != dof: - raise ValueError(f"motor_specs length {len(specs)} does not match dof {dof}") - return specs + if dof != len(cls._DEFAULT_OPENARM_MOTORS): + raise ValueError(f"OpenArmRSAdapter only supports 7 DOF (got {dof})") + if motor_specs is not None: + raise ValueError("openarm_rs is OpenArm-only and does not accept custom motor_specs") + if position_lower is not None or position_upper is not None or velocity_max is not None: + raise ValueError( + "openarm_rs uses fixed OpenArm limits; custom limits require a separate adapter" + ) + return list(cls._DEFAULT_OPENARM_MOTORS) def connect(self) -> bool: try: @@ -201,10 +198,12 @@ def connect(self) -> bool: self._load_gravity_model() self._connected = True self.refresh_state(force=True) - except DMMotorBindingUnavailableError: + except OpenArmRSBindingUnavailableError: raise except Exception as exc: - logger.error(f"DMMotorArm {self._hardware_id}@{self._address} connect failed: {exc}") + logger.error( + f"OpenArmRSAdapter {self._hardware_id}@{self._address} connect failed: {exc}" + ) self._robot = None self._arm = None self._connected = False @@ -247,7 +246,7 @@ def disconnect(self) -> None: self._robot.disable() except Exception as exc: logger.warning( - f"DMMotorArm {self._hardware_id} disable on disconnect failed: {exc}" + f"OpenArmRSAdapter {self._hardware_id} disable on disconnect failed: {exc}" ) self._enabled = False self._connected = False @@ -260,7 +259,7 @@ def is_connected(self) -> bool: def refresh_state(self, *, force: bool = False) -> tuple[list[float], list[float], list[float]]: if self._robot is None or self._arm is None: - raise RuntimeError("DMMotorArm is not connected") + raise RuntimeError("OpenArmRSAdapter is not connected") now = time.monotonic() if ( not force @@ -358,7 +357,7 @@ def write_gravity_compensation(self, damping: float | list[float] = 0.0) -> bool q, dq, _ = self.refresh_state(force=True) tau = self.compute_gravity_torques(q) except Exception as exc: - logger.warning(f"Skipping DMMotor gravity compensation due to invalid state: {exc}") + logger.warning(f"Skipping OpenArm RS gravity compensation due to invalid state: {exc}") return False kd = [float(damping)] * self._dof if isinstance(damping, int | float) else list(damping) return self.write_mit_commands(q=q, dq=dq, kp=self._zero_vector(), kd=kd, tau=tau) @@ -406,7 +405,7 @@ def write_stop(self) -> bool: try: self._robot.disable() except Exception as exc: - logger.warning(f"DMMotorArm {self._hardware_id} stop disable failed: {exc}") + logger.warning(f"OpenArmRSAdapter {self._hardware_id} stop disable failed: {exc}") return False self._enabled = False return True @@ -420,7 +419,7 @@ def write_enable(self, enable: bool) -> bool: else: self._robot.disable() except Exception as exc: - logger.error(f"DMMotorArm {self._hardware_id} enable={enable} failed: {exc}") + logger.error(f"OpenArmRSAdapter {self._hardware_id} enable={enable} failed: {exc}") return False self._enabled = enable return True @@ -432,14 +431,19 @@ def write_clear_errors(self) -> bool: self._robot.disable() self._robot.enable() except Exception as exc: - logger.error(f"DMMotorArm {self._hardware_id} clear errors failed: {exc}") + logger.error(f"OpenArmRSAdapter {self._hardware_id} clear errors failed: {exc}") return False self._enabled = True return True def register(registry: AdapterRegistry) -> None: - registry.register("dm_motor_arm", DMMotorArm) + registry.register("openarm_rs", OpenArmRSAdapter) -__all__ = ["DMMotorArm", "DMMotorBindingUnavailableError", "DMMotorSpecConfig", "register"] +__all__ = [ + "OpenArmRSAdapter", + "OpenArmRSBindingUnavailableError", + "OpenArmRSMotorSpecConfig", + "register", +] diff --git a/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py b/dimos/hardware/manipulators/openarm_rs/test_adapter.py similarity index 83% rename from dimos/hardware/manipulators/dm_motor_arm/test_adapter.py rename to dimos/hardware/manipulators/openarm_rs/test_adapter.py index f9df8421ee..d2e52dbf20 100644 --- a/dimos/hardware/manipulators/dm_motor_arm/test_adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/test_adapter.py @@ -22,9 +22,9 @@ import numpy as np import pytest -from dimos.hardware.manipulators.dm_motor_arm.adapter import ( - DMMotorArm, - DMMotorBindingUnavailableError, +from dimos.hardware.manipulators.openarm_rs.adapter import ( + OpenArmRSAdapter, + OpenArmRSBindingUnavailableError, register, ) from dimos.hardware.manipulators.spec import ControlMode, ManipulatorAdapter @@ -183,23 +183,23 @@ def fake_can_motor_control(monkeypatch: pytest.MonkeyPatch) -> None: def test_implements_manipulator_adapter() -> None: - assert isinstance(DMMotorArm(use_mock_bus=True), ManipulatorAdapter) + assert isinstance(OpenArmRSAdapter(use_mock_bus=True), ManipulatorAdapter) def test_register() -> None: registry = MagicMock() register(registry) - registry.register.assert_called_once_with("dm_motor_arm", DMMotorArm) + registry.register.assert_called_once_with("openarm_rs", OpenArmRSAdapter) def test_defaults_match_openarm_ros2_hardware_presets() -> None: - adapter = DMMotorArm(use_mock_bus=True) + adapter = OpenArmRSAdapter(use_mock_bus=True) assert adapter._kp == pytest.approx([70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0]) assert adapter._kd == pytest.approx([2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5]) def test_canfd_enabled_by_default_for_mock_and_socket_buses() -> None: - mock_adapter = DMMotorArm(use_mock_bus=True) + mock_adapter = OpenArmRSAdapter(use_mock_bus=True) assert mock_adapter.connect() is True mock_robot = FakeRobot.last assert mock_robot is not None @@ -207,7 +207,7 @@ def test_canfd_enabled_by_default_for_mock_and_socket_buses() -> None: assert isinstance(mock_robot.transport, FakeDMControl.MockCanBus) assert mock_robot.transport.fd is True - socket_adapter = DMMotorArm(use_mock_bus=False) + socket_adapter = OpenArmRSAdapter(use_mock_bus=False) assert socket_adapter.connect() is True socket_robot = FakeRobot.last assert socket_robot is not None @@ -217,7 +217,7 @@ def test_canfd_enabled_by_default_for_mock_and_socket_buses() -> None: def test_canfd_flag_and_legacy_fd_override_can_disable_fd() -> None: - canfd_adapter = DMMotorArm(use_mock_bus=True, canfd=False) + canfd_adapter = OpenArmRSAdapter(use_mock_bus=True, canfd=False) assert canfd_adapter.connect() is True canfd_robot = FakeRobot.last assert canfd_robot is not None @@ -225,7 +225,7 @@ def test_canfd_flag_and_legacy_fd_override_can_disable_fd() -> None: assert isinstance(canfd_robot.transport, FakeDMControl.MockCanBus) assert canfd_robot.transport.fd is False - fd_adapter = DMMotorArm(use_mock_bus=True, canfd=True, fd=False) + fd_adapter = OpenArmRSAdapter(use_mock_bus=True, canfd=True, fd=False) assert fd_adapter.connect() is True fd_robot = FakeRobot.last assert fd_robot is not None @@ -234,18 +234,11 @@ def test_canfd_flag_and_legacy_fd_override_can_disable_fd() -> None: assert fd_robot.transport.fd is False -def test_motor_specs_use_binding_motor_type_values() -> None: - adapter = DMMotorArm( - use_mock_bus=True, - dof=2, - motor_specs=[ - {"name": "joint1", "type": "DM4310", "send_id": 1, "recv_id": 17}, - {"name": "joint2", "type": 6, "send_id": 2, "recv_id": 18}, - ], - ) +def test_default_motor_specs_use_binding_motor_type_values() -> None: + adapter = OpenArmRSAdapter(use_mock_bus=True) assert adapter.connect() is True assert adapter._robot is not None - assert adapter._robot.arm.dof == 2 + assert adapter._robot.arm.dof == 7 def test_renamed_can_motor_control_binding_is_used(monkeypatch: pytest.MonkeyPatch) -> None: @@ -259,7 +252,7 @@ def track_can_motor_control(name: str, package: str | None = None) -> ModuleType return real_import_module(name, package) monkeypatch.setattr(importlib, "import_module", track_can_motor_control) - adapter = DMMotorArm(use_mock_bus=True) + adapter = OpenArmRSAdapter(use_mock_bus=True) assert adapter.connect() is True assert imported[:2] == ["can_motor_control", "can_motor_control.damiao"] @@ -273,13 +266,13 @@ def fail_can_motor_control(name: str, package: str | None = None) -> ModuleType: return real_import_module(name, package) monkeypatch.setattr(importlib, "import_module", fail_can_motor_control) - adapter = DMMotorArm(use_mock_bus=True) - with pytest.raises(DMMotorBindingUnavailableError, match="requires.*can-motor-control"): + adapter = OpenArmRSAdapter(use_mock_bus=True) + with pytest.raises(OpenArmRSBindingUnavailableError, match="openarm_rs.*can-motor-control"): adapter.connect() def test_lifecycle_read_write_disable() -> None: - adapter = DMMotorArm(use_mock_bus=True, gravity_comp=False) + adapter = OpenArmRSAdapter(use_mock_bus=True, gravity_comp=False) assert adapter.connect() is True assert adapter.write_enable(True) is True assert adapter.read_enabled() is True @@ -299,7 +292,7 @@ def test_lifecycle_read_write_disable() -> None: def test_state_reads_share_one_tick() -> None: - adapter = DMMotorArm(use_mock_bus=True, state_cache_ttl_s=10.0) + adapter = OpenArmRSAdapter(use_mock_bus=True, state_cache_ttl_s=10.0) assert adapter.connect() is True robot = FakeRobot.last assert robot is not None @@ -314,7 +307,7 @@ def test_state_reads_share_one_tick() -> None: def test_uncached_state_read_queues_no_motion_refresh() -> None: - adapter = DMMotorArm(use_mock_bus=True, state_cache_ttl_s=0.0) + adapter = OpenArmRSAdapter(use_mock_bus=True, state_cache_ttl_s=0.0) assert adapter.connect() is True robot = FakeRobot.last assert robot is not None @@ -329,7 +322,7 @@ def test_uncached_state_read_queues_no_motion_refresh() -> None: def test_default_gains_match_openarm_ros2_presets() -> None: - adapter = DMMotorArm(use_mock_bus=True) + adapter = OpenArmRSAdapter(use_mock_bus=True) assert adapter.connect() is True assert adapter.write_enable(True) is True assert adapter.write_joint_positions([0.1] * 7) is True @@ -343,7 +336,7 @@ def test_default_gains_match_openarm_ros2_presets() -> None: def test_position_commands_use_in_place_gravity_compensation_by_default( monkeypatch: pytest.MonkeyPatch, ) -> None: - adapter = DMMotorArm(use_mock_bus=True, kp=[10.0] * 7, kd=[0.2] * 7) + adapter = OpenArmRSAdapter(use_mock_bus=True, kp=[10.0] * 7, kd=[0.2] * 7) assert adapter.connect() is True assert adapter.write_enable(True) is True monkeypatch.setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) @@ -361,7 +354,7 @@ def test_position_commands_use_in_place_gravity_compensation_by_default( def test_velocity_mode_and_commands_are_unsupported() -> None: - adapter = DMMotorArm(use_mock_bus=True) + adapter = OpenArmRSAdapter(use_mock_bus=True) assert adapter.connect() is True assert adapter.write_enable(True) is True assert adapter.set_control_mode(ControlMode.VELOCITY) is False @@ -374,7 +367,7 @@ def test_velocity_mode_and_commands_are_unsupported() -> None: def test_gravity_comp_false_uses_mit_position_without_effort() -> None: - adapter = DMMotorArm(use_mock_bus=True, gravity_comp=False, kp=[10.0] * 7, kd=[0.2] * 7) + adapter = OpenArmRSAdapter(use_mock_bus=True, gravity_comp=False, kp=[10.0] * 7, kd=[0.2] * 7) assert adapter.connect() is True assert adapter.write_enable(True) is True assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True @@ -390,7 +383,7 @@ def test_gravity_comp_false_uses_mit_position_without_effort() -> None: def test_gravity_compensation_command_shape(monkeypatch: pytest.MonkeyPatch) -> None: - adapter = DMMotorArm(use_mock_bus=True) + adapter = OpenArmRSAdapter(use_mock_bus=True) assert adapter.connect() is True assert adapter.write_enable(True) is True monkeypatch.setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) @@ -404,27 +397,34 @@ def test_gravity_compensation_command_shape(monkeypatch: pytest.MonkeyPatch) -> assert adapter.get_control_mode() == ControlMode.TORQUE -def test_non_openarm_dof_requires_explicit_motor_specs() -> None: - with pytest.raises(ValueError, match="motor_specs is required"): - DMMotorArm(dof=2, use_mock_bus=True) +def test_non_openarm_dof_is_rejected() -> None: + with pytest.raises(ValueError, match="only supports 7 DOF"): + OpenArmRSAdapter(dof=2, use_mock_bus=True) + + +def test_custom_non_openarm_metadata_is_rejected() -> None: + with pytest.raises(ValueError, match="does not accept custom motor_specs"): + OpenArmRSAdapter( + use_mock_bus=True, + motor_specs=[ + {"name": "shoulder", "type": "DM4310", "send_id": 1, "recv_id": 17}, + ], + ) + + with pytest.raises(ValueError, match="fixed OpenArm limits"): + OpenArmRSAdapter( + use_mock_bus=True, + position_lower=[-0.5] * 7, + ) -def test_custom_non_openarm_dof_uses_explicit_metadata() -> None: - adapter = DMMotorArm( - dof=2, +def test_openarm_rs_info_is_openarm_specific() -> None: + adapter = OpenArmRSAdapter( use_mock_bus=True, - motor_specs=[ - {"name": "shoulder", "type": "DM4310", "send_id": 1, "recv_id": 17}, - {"name": "elbow", "type": "DM4310", "send_id": 2, "recv_id": 18}, - ], - position_lower=[-0.5, -0.25], - position_upper=[0.5, 0.25], - velocity_max=[1.0, 2.0], - kp=[3.0, 4.0], - kd=[0.3, 0.4], + kp=[3.0] * 7, + kd=[0.3] * 7, ) - assert adapter.get_dof() == 2 - assert [motor.name for motor in adapter._motor_specs] == ["shoulder", "elbow"] - assert adapter.get_limits().position_lower == [-0.5, -0.25] - assert adapter._kp == [3.0, 4.0] - assert adapter._kd == [0.3, 0.4] + assert adapter.get_info().vendor == "Enactic" + assert adapter.get_info().model == "OpenArm RS v10" + assert adapter.get_dof() == 7 + assert [motor.name for motor in adapter._motor_specs] == [f"joint{i}" for i in range(1, 8)] diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 4bad6df5a4..dd6a2174a3 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -21,8 +21,6 @@ "coordinator-cartesian-ik-mock": "dimos.control.blueprints.teleop:coordinator_cartesian_ik_mock", "coordinator-cartesian-ik-piper": "dimos.control.blueprints.teleop:coordinator_cartesian_ik_piper", "coordinator-combined-xarm6": "dimos.control.blueprints.teleop:coordinator_combined_xarm6", - "coordinator-dm-motor-openarm": "dimos.robot.manipulators.openarm.blueprints:coordinator_dm_motor_openarm", - "coordinator-dm-motor-openarm-hold-test": "dimos.robot.manipulators.openarm.blueprints:coordinator_dm_motor_openarm_hold_test", "coordinator-dual-mock": "dimos.control.blueprints.dual:coordinator_dual_mock", "coordinator-dual-xarm": "dimos.control.blueprints.dual:coordinator_dual_xarm", "coordinator-flowbase": "dimos.control.blueprints.mobile:coordinator_flowbase", @@ -35,6 +33,8 @@ "coordinator-openarm-left": "dimos.robot.manipulators.openarm.blueprints:coordinator_openarm_left", "coordinator-openarm-mock": "dimos.robot.manipulators.openarm.blueprints:coordinator_openarm_mock", "coordinator-openarm-right": "dimos.robot.manipulators.openarm.blueprints:coordinator_openarm_right", + "coordinator-openarm-rs": "dimos.robot.manipulators.openarm.blueprints:coordinator_openarm_rs", + "coordinator-openarm-rs-hold-test": "dimos.robot.manipulators.openarm.blueprints:coordinator_openarm_rs_hold_test", "coordinator-piper": "dimos.control.blueprints.basic:coordinator_piper", "coordinator-piper-xarm": "dimos.control.blueprints.dual:coordinator_piper_xarm", "coordinator-servo-xarm6": "dimos.control.blueprints.teleop:coordinator_servo_xarm6", diff --git a/dimos/robot/manipulators/openarm/blueprints.py b/dimos/robot/manipulators/openarm/blueprints.py index 22ebe67d87..b9b8f6ab1d 100644 --- a/dimos/robot/manipulators/openarm/blueprints.py +++ b/dimos/robot/manipulators/openarm/blueprints.py @@ -60,7 +60,7 @@ AUTO_SET_MIT_MODE = True _ADAPTER_KWARGS = {"auto_set_mit_mode": AUTO_SET_MIT_MODE} -_DM_MOTOR_ADAPTER_KWARGS = { +_OPENARM_RS_ADAPTER_KWARGS = { "gravity_model_path": OPENARM_V10_RIGHT_MODEL, "gravity_comp": True, "canfd": True, @@ -77,13 +77,16 @@ adapter_type="openarm", adapter_kwargs=_ADAPTER_KWARGS, ) -_dm_motor_hw = _openarm( +_openarm_rs_hw = _openarm( side="right", name="arm", - adapter_type="dm_motor_arm", + adapter_type="openarm_rs", address=RIGHT_CAN, - adapter_kwargs=_DM_MOTOR_ADAPTER_KWARGS, + adapter_kwargs=_OPENARM_RS_ADAPTER_KWARGS, ) +_openarm_rs_joint_names = _openarm_rs_hw.joint_names +if _openarm_rs_joint_names is None: + raise ValueError("OpenArm RS hardware config requires joint names") coordinator_openarm_left = ControlCoordinator.blueprint( hardware=[_left_hw.to_hardware_component()], @@ -116,9 +119,9 @@ } ) -coordinator_dm_motor_openarm = ControlCoordinator.blueprint( - hardware=[_dm_motor_hw.to_hardware_component()], - tasks=[_dm_motor_hw.to_task_config(task_name="traj_dm_motor_openarm")], +coordinator_openarm_rs = ControlCoordinator.blueprint( + hardware=[_openarm_rs_hw.to_hardware_component()], + tasks=[_openarm_rs_hw.to_task_config(task_name="traj_openarm_rs")], ).transports( { ("joint_state", JointState): LCMTransport("/coordinator/joint_state", JointState), @@ -126,15 +129,15 @@ ) # Hardware bring-up test: immediately writes current-position SERVO_POSITION -# commands so the dm_motor_arm adapter emits MIT hold frames with gravity +# commands so the openarm_rs adapter emits MIT hold frames with gravity # feed-forward. Use cautiously; this is an active write-path test. -coordinator_dm_motor_openarm_hold_test = ControlCoordinator.blueprint( - hardware=[_dm_motor_hw.to_hardware_component()], +coordinator_openarm_rs_hold_test = ControlCoordinator.blueprint( + hardware=[_openarm_rs_hw.to_hardware_component()], tasks=[ TaskConfig( - name="hold_dm_motor_openarm", + name="hold_openarm_rs", type="current_position_hold", - joint_names=_dm_motor_hw.joint_names, + joint_names=_openarm_rs_joint_names, priority=5, auto_start=True, ), @@ -251,11 +254,12 @@ __all__ = [ - "coordinator_dm_motor_openarm", "coordinator_openarm_bimanual", "coordinator_openarm_left", "coordinator_openarm_mock", "coordinator_openarm_right", + "coordinator_openarm_rs", + "coordinator_openarm_rs_hold_test", "keyboard_teleop_openarm", "keyboard_teleop_openarm_mock", "openarm_mock_planner_coordinator", diff --git a/docs/capabilities/manipulation/openarm_integration.md b/docs/capabilities/manipulation/openarm_integration.md index 007492aa14..862e3a2002 100644 --- a/docs/capabilities/manipulation/openarm_integration.md +++ b/docs/capabilities/manipulation/openarm_integration.md @@ -22,16 +22,16 @@ Every other arm in dimos wraps a vendor Python SDK: | Go2 / G1 | WebRTC | Unitree SDK | | Panda | FCI | `panda-py` | -**OpenArm historically shipped no stable Python SDK.** The default `openarm` adapter still uses dimos' in-tree raw-CAN driver and remains the existing production path. It is now implemented as an explicit OpenArm subclass over shared Damiao metadata and gravity/validation helpers, so users still select `adapter_type="openarm"` for OpenArm hardware. DimOS also provides an opt-in `dm_motor_arm` adapter for environments that install the Rust-backed `can-motor-control` Python binding through the manipulation extra. +**OpenArm historically shipped no stable Python SDK.** The default `openarm` adapter still uses dimos' in-tree raw-CAN driver and remains the existing production path, so users still select `adapter_type="openarm"` for OpenArm hardware. DimOS also provides an opt-in `openarm_rs` adapter for environments that install the Rust-backed `can-motor-control` Python binding through the manipulation extra. ## Adapter paths | Adapter | Hardware API | Dependency expectation | Typical use | |---|---|---|---| | `openarm` | In-tree SocketCAN Damiao driver | `python-can` plus Pinocchio for gravity feed-forward | Existing OpenArm coordinator, planner, and teleop blueprints. | -| `dm_motor_arm` | Rust-backed `can-motor-control` Python binding | Install `dimos[manipulation]` so `can_motor_control` is importable | DMMotor bring-up, binding-backed coordinator operation, and gravity-compensation-only validation. | +| `openarm_rs` | Rust-backed `can-motor-control` Python binding | Install `dimos[manipulation]` so `can_motor_control` is importable | OpenArm RS bring-up, binding-backed coordinator operation, and gravity-compensation-only validation. | -Selecting `dm_motor_arm` is explicit through blueprint or hardware config. Registry discovery remains available without `can_motor_control`; selecting the adapter fails with a clear missing-binding error if the package is absent. Future Damiao-based arms should subclass the shared Damiao adapter base with their own typed motor/gain/limit metadata instead of relying on OpenArm defaults. +Selecting `openarm_rs` is explicit through blueprint or hardware config. Registry discovery remains available without `can_motor_control`; selecting the adapter fails with a clear missing-binding error if the package is absent. Future Damiao-based arms should subclass the shared Damiao adapter base with their own typed motor/gain/limit metadata instead of relying on OpenArm defaults. ## Architecture @@ -122,7 +122,7 @@ The register is persistent across power cycles, so you only need this once per m | `coordinator-openarm-bimanual` | Both arms, real hardware, no planner. | | `openarm-planner-coordinator` | **Main usable blueprint** — Drake planner + both arms on real hardware. | | `keyboard-teleop-openarm-mock` / `keyboard-teleop-openarm` | Single-arm Cartesian IK + pygame keyboard, mock / real. | -| `coordinator-dm-motor-openarm` | Opt-in single-arm coordinator path using `adapter_type="dm_motor_arm"`; gravity feed-forward is enabled in the adapter by default. | +| `coordinator-openarm-rs` | Opt-in single-arm coordinator path using `adapter_type="openarm_rs"`; gravity feed-forward is enabled in the adapter by default. | **Safety before hot-plugging hardware:** hold the arms before starting. On connect, the adapter enables all motors and sends gravity-comp holds — the arms go slightly stiff but don't leap. Ctrl-C to cleanly disable and exit. @@ -139,22 +139,22 @@ dimos run coordinator-openarm-left dimos run openarm-planner-coordinator ``` -For the `dm_motor_arm` binding path, stage validation before trajectory control: binding mock or vcan, one motor enable/read, one motor low-rate hold, full-arm state monitor, adapter gravity compensation, then trajectory-control validation. +For the `openarm_rs` binding path, stage validation before trajectory control: binding mock or vcan, one motor enable/read, one motor low-rate hold, full-arm state monitor, adapter gravity compensation, then trajectory-control validation. ```bash # requires the can-motor-control binding from dimos[manipulation] sudo MODE=fd ./dimos/robot/manipulators/openarm/scripts/openarm_can_up.sh can0 # read/trajectory coordinator: writes only after a task receives a command -dimos run coordinator-dm-motor-openarm +dimos run coordinator-openarm-rs # active write-path test: immediately holds current q with MIT + gravity feed-forward -dimos run coordinator-dm-motor-openarm-hold-test +dimos run coordinator-openarm-rs-hold-test ``` -The hold-test blueprint auto-starts a current-position hold task. It reads the current joints and writes those same positions every coordinator tick so the `DMMotorArm` adapter emits MIT position frames with gravity feed-forward. Use it only when the arm is supported and you intentionally want an active hardware write-path test. +The hold-test blueprint auto-starts a current-position hold task. It reads the current joints and writes those same positions every coordinator tick so `OpenArmRSAdapter` emits MIT position frames with gravity feed-forward. Use it only when the arm is supported and you intentionally want an active hardware write-path test. -The `DMMotorArm` adapter opens CAN-FD by default (`canfd=True`) and computes model gravity feed-forward in-place when `gravity_comp=True` (the OpenArm blueprint default). It intentionally supports position and effort semantics only: position commands are sent as MIT commands with preset `kp/kd` gains and optional gravity feed-forward, while effort/gravity-only commands use `kp=0` so the arm does not hold a target pose. Velocity commands are rejected because nonzero gains make MIT commands maintain the supplied `q`. Set `gravity_comp=False` in adapter kwargs to keep position MIT commands but omit model feed-forward torque. +`OpenArmRSAdapter` opens CAN-FD by default (`canfd=True`) and computes model gravity feed-forward in-place when `gravity_comp=True` (the OpenArm blueprint default). It intentionally supports position and effort semantics only: position commands are sent as MIT commands with preset `kp/kd` gains and optional gravity feed-forward, while effort/gravity-only commands use `kp=0` so the arm does not hold a target pose. Velocity commands are rejected because nonzero gains make MIT commands maintain the supplied `q`. Set `gravity_comp=False` in adapter kwargs to keep position MIT commands but omit model feed-forward torque. Meshcat will appear at http://localhost:7000. @@ -253,11 +253,11 @@ LEFT_CAN = "can1" RIGHT_CAN = "can0" ``` -No other code changes are needed. The `coordinator-dm-motor-openarm` blueprint currently targets `RIGHT_CAN` (`can0`) and passes `canfd=True`; use `sudo MODE=fd ... openarm_can_up.sh can0` before running it. +No other code changes are needed. The `coordinator-openarm-rs` blueprint currently targets `RIGHT_CAN` (`can0`) and passes `canfd=True`; use `sudo MODE=fd ... openarm_can_up.sh can0` before running it. ### Gain tuning (MIT kp/kd) -Defaults live in the adapter implementations. The binding-backed `dm_motor_arm` path uses the upstream OpenArm ROS2 hardware presets from `openarm_hardware/openarm_simple_hardware.hpp`; the in-tree `openarm` adapter keeps its existing gains. DMMotor/OpenArm ROS2 presets are: +Defaults live in the adapter implementations. The binding-backed `openarm_rs` path uses the upstream OpenArm ROS2 hardware presets from `openarm_hardware/openarm_simple_hardware.hpp`; the in-tree `openarm` adapter keeps its existing gains. OpenArm RS/OpenArm ROS2 presets are: ```python _DEFAULT_KP = [70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0] @@ -268,7 +268,7 @@ Guidelines: - `kp ∈ [0, 500]` in MIT mode. Higher kp = stiffer position tracking; too high → oscillation. - `kd ∈ [0, 5]`. Higher kd = more damping, but values above ~2 on these gearboxes cause high-frequency buzz/grinding. - Gravity compensation is on by default (`gravity_comp=True`) for OpenArm-style adapters. The adapter uses Pinocchio to compute `G(q)` and adds it as feedforward torque in the same command path. This removes the need for very high kp to fight gravity, so prefer upstream-tuned kp/kd + gravity comp over increasing kp. -- For `dm_motor_arm`, use position commands for trajectory/coordinator control and direct effort/gravity-only commands for torque bring-up. Velocity commands are intentionally unsupported. +- For `openarm_rs`, use position commands for trajectory/coordinator control and direct effort/gravity-only commands for torque bring-up. Velocity commands are intentionally unsupported. ### Physical joint limits @@ -374,7 +374,7 @@ Persistent across power cycles. ## Design decisions - **Driver separate from adapter.** `driver.py` has zero dimos deps → unit-testable with a virtual CAN bus, reusable outside dimos. -- **MIT mode for DMMotor position/effort.** The binding-backed `dm_motor_arm` path intentionally rejects velocity commands; nonzero MIT gains hold `q`, so velocity semantics are not exposed. Position uses preset `kp/kd`; effort/gravity-only commands use `kp=0`. +- **MIT mode for OpenArm RS position/effort.** The binding-backed `openarm_rs` path intentionally rejects velocity commands; nonzero MIT gains hold `q`, so velocity semantics are not exposed. Position uses preset `kp/kd`; effort/gravity-only commands use `kp=0`. - **Gravity compensation on by default.** Eliminates steady-state position error without needing high kp. Needs Pinocchio + the per-side URDFs. - **One adapter per CAN bus, keyed by `address`.** Matches the Piper adapter pattern. Bimanual = two adapters with different `address` values. - **Per-side URDFs for Drake planning.** Loading the full 14-DOF bimanual URDF twice (once per robot instance) creates phantom-arm collisions with the "other" arm frozen at zero. The per-side URDFs keep only one arm's links + the torso, avoiding the phantom collisions while matching the bimanual kinematics exactly. diff --git a/openspec/changes/add-dm-motor-arm-adapter/.openspec.yaml b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/.openspec.yaml similarity index 100% rename from openspec/changes/add-dm-motor-arm-adapter/.openspec.yaml rename to openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/.openspec.yaml diff --git a/openspec/changes/add-dm-motor-arm-adapter/design.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/design.md similarity index 100% rename from openspec/changes/add-dm-motor-arm-adapter/design.md rename to openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/design.md diff --git a/openspec/changes/add-dm-motor-arm-adapter/docs.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/docs.md similarity index 100% rename from openspec/changes/add-dm-motor-arm-adapter/docs.md rename to openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/docs.md diff --git a/openspec/changes/add-dm-motor-arm-adapter/proposal.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/proposal.md similarity index 100% rename from openspec/changes/add-dm-motor-arm-adapter/proposal.md rename to openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/proposal.md diff --git a/openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md similarity index 100% rename from openspec/changes/add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md rename to openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md diff --git a/openspec/changes/add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md similarity index 100% rename from openspec/changes/add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md rename to openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md diff --git a/openspec/changes/add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md similarity index 100% rename from openspec/changes/add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md rename to openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md diff --git a/openspec/changes/add-dm-motor-arm-adapter/tasks.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/tasks.md similarity index 100% rename from openspec/changes/add-dm-motor-arm-adapter/tasks.md rename to openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/tasks.md diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/.openspec.yaml b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/.openspec.yaml new file mode 100644 index 0000000000..b2c3a043ce --- /dev/null +++ b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-06 diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/design.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/design.md new file mode 100644 index 0000000000..20fe5089d9 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/design.md @@ -0,0 +1,122 @@ +## Context + +DimOS manipulator hardware is selected through `HardwareComponent.adapter_type`, discovered by the manipulator adapter registry, and consumed by `ControlCoordinator` through the existing `ManipulatorAdapter` protocol. The current branch has two OpenArm-related paths: the existing `openarm` adapter with an in-tree SocketCAN Damiao driver, and a newer binding-backed path currently named `dm_motor_arm` that imports `can_motor_control` lazily and drives an OpenArm-style arm through a Rust-backed Python binding. + +The prior refactor moved `OpenArmAdapter` onto a shared Damiao base. The updated cleanup direction is stricter: keep the original `openarm` adapter source and behavior stable, and isolate the binding-backed path under an explicit OpenArm RS name. Shared Damiao metadata helpers remain useful, but they should not force the original OpenArm adapter to inherit new behavior. + +Relevant surfaces include `dimos/hardware/manipulators/openarm/adapter.py`, `dimos/hardware/manipulators/dm_motor_arm/adapter.py`, `dimos/hardware/manipulators/damiao/specs.py`, `dimos/hardware/manipulators/damiao/base_adapter.py`, `dimos/hardware/manipulators/registry.py`, `dimos/robot/manipulators/openarm/blueprints.py`, `dimos/robot/all_blueprints.py`, and `docs/capabilities/manipulation/openarm_integration.md`. + +## Goals / Non-Goals + +**Goals:** + +- Keep `adapter_type="openarm"` and `OpenArmAdapter` as the stable in-tree OpenArm CAN adapter. +- Restore or preserve the original OpenArm adapter source-level boundary so this cleanup does not change its implementation behavior. +- Rename the binding-backed OpenArm path to `openarm_rs` and expose it as OpenArm-only. +- Keep lazy optional import behavior for the binding-backed path so adapter discovery works without `can_motor_control` installed. +- Keep ControlCoordinator, joint-state streams, joint command streams, task configs, and MCP/skills unchanged. +- Add readable docstrings to Damiao metadata helpers. +- Update docs, tests, and generated blueprint registry entries to match the new names. + +**Non-Goals:** + +- Do not build or vendor the Rust binding in DimOS. +- Do not introduce a broad dynamic YAML/TOML schema for arbitrary Damiao arms. +- Do not make `openarm_rs` a generic Damiao/DMMotor adapter in this cleanup. +- Do not change the public ControlCoordinator stream contract or add new MCP/skill tools. +- Do not perform real hardware validation as part of artifact generation; leave that as staged QA. + +## DimOS Architecture + +External coordinator shape after cleanup: + +```text +ControlCoordinator + -> HardwareComponent(adapter_type="openarm" | "openarm_rs") + -> manipulator adapter registry + -> OpenArmAdapter # original in-tree OpenArm CAN path + -> OpenArmRSAdapter # Rust-backed binding path for OpenArm only +``` + +Recommended code shape: + +```text +dimos/hardware/manipulators/openarm/adapter.py + OpenArmAdapter # standalone/stable source-level OpenArm adapter + +dimos/hardware/manipulators/openarm_rs/adapter.py + OpenArmRSAdapter # renamed binding-backed OpenArm adapter + +dimos/hardware/manipulators/damiao/ + specs.py # typed Damiao metadata and validation helpers + base_adapter.py # shared metadata/limits/control-mode/gravity helpers for adapters that opt in +``` + +The binding-backed adapter should continue to satisfy `ManipulatorAdapter`. It may inherit from `DamiaoArmAdapterBase` if that remains useful for validation, limits, and gravity helpers. The original `OpenArmAdapter` should not be forced through the shared base in this cleanup; preserving its current stable behavior is the compatibility target. + +No new DimOS `Spec` Protocol is required. No module streams, transports, RPC references, skills, or MCP tools change. If runnable blueprint variable names change from `coordinator_dm_motor_openarm*` to `coordinator_openarm_rs*`, regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py`. + +## Decisions + +- **Rename by user-facing role, not motor family.** The adapter is used as an OpenArm binding-backed path, so `openarm_rs` is clearer than `dm_motor_arm`. +- **Keep `openarm` source-level stable.** The existing in-tree OpenArm adapter is the stable path; source-level changes risk hardware behavior drift even if tests pass. +- **Keep optional binding failures selected-adapter scoped.** Import `can_motor_control` only when the `openarm_rs` adapter is selected or connected. +- **Do not preserve unreleased generic configurability.** If the current branch allowed arbitrary non-OpenArm `motor_specs`, remove it or require a future explicit generic Damiao adapter change. +- **Keep shared Damiao helpers modest.** `damiao/specs.py` and `base_adapter.py` should provide metadata validation, limits, control-mode state, and gravity helpers, not own all binding lifecycle details. +- **Treat blueprint renames as public CLI changes.** Runnable names exposed by `dimos list` / `dimos run` need docs and generated registry updates. + +## Safety / Simulation / Replay + +Hardware assumptions: + +- `openarm` remains the stable OpenArm hardware path using the in-tree SocketCAN driver. +- `openarm_rs` requires the Rust-backed `can_motor_control` Python binding from the manipulation extra on supported platforms. +- Binding-backed operation may use CAN-FD and staged mock/vcan validation before real hardware. +- Gravity compensation requires a valid OpenArm model path, joint ordering, signs, and measured state. + +Safety constraints: + +- Do not silently migrate existing OpenArm blueprints from `openarm` to `openarm_rs`. +- Preserve disable-on-disconnect and safe stop behavior for both adapter paths. +- Keep gravity-compensation-only commands free-moving by using zero position stiffness where applicable. +- Keep missing-binding errors explicit and selected-adapter scoped. +- Validate mock/vcan before real hardware; real hardware QA should start with supported one-arm low-rate tests. + +Simulation/replay: + +- Replay is out of scope. +- Existing OpenArm mock/planner blueprints should remain available. +- Binding-backed mock/vcan tests should cover registry discovery, connect, enable, state reads, command writes, stop, and disconnect. + +Manual QA surface: + +- Library surface: discover `openarm` and `openarm_rs` adapter keys. +- CLI surface: `dimos list` includes renamed `openarm_rs` runnable blueprints if names change. +- Binding surface: missing `can_motor_control` produces a clear selected-adapter error. +- Mock/vcan surface: `openarm_rs` can connect, enable, read one coherent state snapshot, write a supported command, stop, and disconnect. + +## Risks / Trade-offs + +- **Reverting OpenArm refactor may duplicate small helpers.** This is acceptable to protect stable hardware behavior; shared helpers can still serve the binding-backed path. +- **Renaming branch-only surfaces may require broad updates.** Mitigate by searching docs, tests, blueprints, registry output, and adapter strings for `dm_motor_arm` and `coordinator-dm-motor-openarm`. +- **`openarm_rs` may imply a specific upstream crate.** Document that the adapter consumes the currently selected `can_motor_control` Python binding and does not vendor Rust crates. +- **Generated registry drift.** Mitigate by running the blueprint generation test whenever runnable blueprint exports change. +- **Hardware safety drift.** Mitigate with focused adapter parity tests and staged mock/vcan before real hardware. + +## Migration / Rollout + +1. Restore or preserve `openarm/adapter.py` as the stable standalone adapter while keeping `adapter_type="openarm"` unchanged. +2. Move or rename `dm_motor_arm` to `openarm_rs`, including class names, package exports, registry key, tests, and missing-binding messages. +3. Narrow the renamed adapter to OpenArm-specific defaults and reject or remove generic non-OpenArm construction paths. +4. Add Damiao metadata docstrings without changing metadata behavior. +5. Rename OpenArm binding-backed blueprints and task names from `dm_motor` wording to `openarm_rs` wording. +6. Update docs and generated registry output if blueprint exports change. +7. Run OpenSpec validation, focused tests, blueprint generation validation, docs validation, and manual QA through registry/CLI/mock surfaces. + +Rollback is straightforward if changes stay isolated: keep the original `openarm` adapter untouched, and revert the `openarm_rs` package/blueprint/docs rename if the binding-backed path needs more design work. + +## Open Questions + +- Should the old `dm_motor_arm` registry key exist as a temporary alias during this unreleased branch, or should it be removed entirely to avoid confusion? +- Should package naming be `openarm_rs` only, or should docs describe it as “OpenArm RS / Rust-backed OpenArm” for readability? +- Should the future generic Damiao adapter be planned as a separate change after OpenArm RS lands? diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/docs.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/docs.md new file mode 100644 index 0000000000..cd1ee158c6 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/docs.md @@ -0,0 +1,28 @@ +## User-Facing Docs + +- Update `docs/capabilities/manipulation/openarm_integration.md` to describe two OpenArm adapter paths: + - `openarm`: stable in-tree SocketCAN OpenArm adapter. + - `openarm_rs`: explicit Rust-backed / `can_motor_control` binding path for OpenArm. +- Replace `dm_motor_arm` and `coordinator-dm-motor-openarm*` user-facing references with `openarm_rs` and the renamed blueprint names. +- Clarify that existing OpenArm blueprints continue to use `adapter_type="openarm"` unless a user explicitly selects the binding-backed path. +- Clarify staged validation for the `openarm_rs` path: binding install, mock/vcan, one-arm state read, low-rate hold/gravity compensation, then trajectory validation. + +## Contributor Docs + +- Update contributor-facing manipulation docs only if they currently recommend `dm_motor_arm` as a generic Damiao extension point. +- If `dimos/hardware/manipulators/README.md` is changed, describe `damiao/specs.py` as typed metadata/validation for Damiao-based adapters and `openarm_rs` as an OpenArm-specific binding-backed adapter. + +## Coding-Agent Docs + +- No AGENTS.md update is expected. +- Update `docs/coding-agents/` only if existing guidance mentions the `dm_motor_arm` adapter key or recommends editing the original `openarm` adapter for binding-backed OpenArm work. + +## Doc Validation + +- Run docs link validation if links or blueprint names are changed in user-facing docs. +- Run `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable code blocks are added or changed. +- If only prose/table names change and no executable blocks are touched, focused review plus repository docs validation is sufficient. + +## No Docs Needed + +Documentation changes are needed because this cleanup renames user-facing adapter and blueprint selection surfaces and clarifies hardware bring-up guidance. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/proposal.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/proposal.md new file mode 100644 index 0000000000..affff95f4e --- /dev/null +++ b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/proposal.md @@ -0,0 +1,40 @@ +## Why + +The current Damiao/OpenArm adapter changes have blurred two separate surfaces: the existing `openarm` adapter, which should remain the stable in-tree OpenArm CAN path, and the newer Rust-backed binding path currently named `dm_motor_arm`. The binding-backed path is being used as an OpenArm bring-up adapter, but its generic name and configurable defaults make it look like a general Damiao arm adapter. + +This cleanup matters now because the branch is close to landing and hardware-facing adapter naming, safety behavior, and compatibility expectations need to be clear before implementation accumulates more call sites. The goal is to keep existing OpenArm users on the unchanged `openarm` adapter while making the Rust-backed OpenArm path explicit and easier to reason about. + +## What Changes + +- Rename and scope the Rust-backed OpenArm adapter path from the generic `dm_motor_arm` surface to an explicit `openarm_rs` surface. +- Preserve the existing `openarm` adapter as the stable OpenArm adapter and avoid source-level changes to its behavior in this cleanup. +- Keep shared Damiao metadata and validation helpers under `dimos/hardware/manipulators/damiao/` for the Rust-backed OpenArm path and future Damiao adapters. +- Add clearer class/function docstrings to Damiao metadata helpers so motor specs, arm specs, validation, and recv-id defaults are understandable. +- Update OpenArm blueprints, docs, tests, and generated registry entries that currently expose the `dm_motor_arm` OpenArm path. +- **BREAKING** for unreleased branch-only usage: the binding-backed OpenArm adapter key and blueprint names should use `openarm_rs` instead of `dm_motor_arm` / `coordinator-dm-motor-openarm`. + +## Affected DimOS Surfaces + +- Modules/streams: manipulator adapter implementations and tests; no changes to ControlCoordinator streams or joint command/state message contracts. +- Blueprints/CLI: OpenArm blueprint exports and `dimos run` blueprint names for the binding-backed OpenArm path; adapter registry key for the binding-backed path. +- Skills/MCP: no skill or MCP tool changes. +- Hardware/simulation/replay: OpenArm hardware adapter selection and binding-backed mock/vcan bring-up path; no replay behavior changes. +- Docs/generated registries: OpenArm integration docs, possible manipulator contributor docs, and `dimos/robot/all_blueprints.py` if runnable blueprint names change. + +## Capabilities + +### New Capabilities + +- `openarm-rs-adapter-selection`: User-visible selection, naming, and safety expectations for the Rust-backed OpenArm adapter path. +- `damiao-adapter-metadata-docs`: Developer-visible readability and documentation expectations for Damiao arm metadata helpers. + +### Modified Capabilities + +- `openarm-adapter-compatibility`: Preserve the existing `openarm` adapter path while adding the explicit `openarm_rs` path. +- `dm-motor-manipulator-adapters`: Rename and narrow the current binding-backed OpenArm behavior so it is not presented as a generic Damiao/DMMotor adapter. + +## Impact + +Existing `openarm` users should see no behavior change: adapter selection, constructor arguments, blueprints, safety behavior, and in-tree CAN driver operation remain stable. Developers using the branch-only `dm_motor_arm` OpenArm path will need to switch to `openarm_rs` names before implementation or docs are considered complete. + +Compatibility risk is concentrated around registry discovery, blueprint generation, and documentation drift. Testing should cover adapter registry keys, OpenArm compatibility tests, Rust-backed OpenArm mock/vcan behavior, focused Damiao metadata validation, OpenSpec validation, blueprint registry generation if names change, and manual QA through the library or `dimos run` surfaces. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/damiao-adapter-metadata-docs/spec.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/damiao-adapter-metadata-docs/spec.md new file mode 100644 index 0000000000..717f4f7a11 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/damiao-adapter-metadata-docs/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Readable Damiao metadata documentation + +DimOS SHALL provide readable developer documentation for Damiao adapter metadata helpers so future maintainers can understand motor order, identifiers, limits, gains, and validation behavior. + +#### Scenario: Reading motor metadata helpers +- **GIVEN** a developer opens the Damiao metadata helper module +- **WHEN** they inspect the motor metadata type and receive-ID behavior +- **THEN** the documentation SHALL explain the meaning of joint order, motor type, send ID, receive ID, and default receive-ID derivation +- **AND** it MUST make clear that the metadata is used before hardware commands are sent. + +#### Scenario: Reading arm metadata helpers +- **GIVEN** a developer opens the Damiao arm metadata type +- **WHEN** they inspect arm limits, gains, gravity model fields, bus fields, or validation helpers +- **THEN** the documentation SHALL explain that these values describe explicit adapter-owned hardware metadata +- **AND** it SHALL distinguish metadata coercion and validation from runtime hardware communication. + +### Requirement: Damiao metadata validation readability + +DimOS SHALL keep Damiao metadata validation behavior understandable without requiring readers to infer intent from tests alone. + +#### Scenario: Understanding validation failures +- **GIVEN** an adapter provides duplicate IDs or mismatched vector lengths +- **WHEN** validation rejects the metadata +- **THEN** the helper documentation SHALL make the validation intent clear to developers +- **AND** validation errors MUST remain focused on preventing malformed metadata from reaching hardware command paths. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/dm-motor-manipulator-adapters/spec.md new file mode 100644 index 0000000000..e7e8cc1b23 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/dm-motor-manipulator-adapters/spec.md @@ -0,0 +1,39 @@ +## MODIFIED Requirements + +### Requirement: Binding-backed OpenArm adapter scope + +DimOS SHALL narrow the current binding-backed DMMotor/OpenArm behavior into an explicit OpenArm RS adapter path rather than presenting it as a generic DMMotor arm adapter. + +#### Scenario: Selecting the renamed binding-backed adapter +- **GIVEN** a hardware configuration needs the Rust-backed OpenArm binding path +- **WHEN** the configuration selects an adapter type +- **THEN** it SHALL use `openarm_rs` for the binding-backed OpenArm adapter +- **AND** it SHALL NOT rely on `dm_motor_arm` as the documented OpenArm binding-backed adapter key. + +#### Scenario: Avoiding generic Damiao defaults +- **GIVEN** a non-OpenArm Damiao arm needs support in the future +- **WHEN** a developer evaluates the OpenArm RS adapter +- **THEN** DimOS SHALL make clear that OpenArm RS metadata and defaults are OpenArm-specific +- **AND** DimOS MUST require a separate explicit adapter or change before treating those defaults as generic Damiao behavior. + +### Requirement: Binding-backed adapter safety behavior + +DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. + +#### Scenario: Coherent state reads +- **GIVEN** the OpenArm RS adapter is connected through the binding-backed path +- **WHEN** DimOS reads positions, velocities, and efforts during one coordinator cycle +- **THEN** the adapter SHALL provide a coherent state snapshot rather than independently loading the CAN bus for each read +- **AND** stale or malformed state SHALL be surfaced before unsafe commands are sent. + +#### Scenario: Gravity-compensation-only command +- **GIVEN** a user invokes a gravity-compensation-only validation path through the binding-backed adapter +- **WHEN** the adapter sends MIT commands for gravity compensation +- **THEN** the command SHALL use zero position stiffness so the arm remains manually movable +- **AND** the command SHALL use current measured state and configured OpenArm gravity metadata. + +#### Scenario: Missing binding remains selected-adapter scoped +- **GIVEN** the optional binding is not installed +- **WHEN** DimOS discovers manipulator adapters without selecting OpenArm RS +- **THEN** discovery SHALL continue for adapters that do not require the binding +- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-adapter-compatibility/spec.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-adapter-compatibility/spec.md new file mode 100644 index 0000000000..33a37247c5 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-adapter-compatibility/spec.md @@ -0,0 +1,39 @@ +## MODIFIED Requirements + +### Requirement: OpenArm adapter compatibility + +DimOS SHALL preserve the existing OpenArm adapter selection and observable behavior while keeping the binding-backed OpenArm RS path separate. + +#### Scenario: Selecting the OpenArm adapter +- **GIVEN** an existing hardware configuration selects the OpenArm adapter +- **WHEN** DimOS initializes the manipulator hardware +- **THEN** DimOS SHALL continue to create an OpenArm-specific adapter through the existing `openarm` adapter selection surface +- **AND** the adapter SHALL preserve OpenArm-specific side, joint, motor, limit, gain, gravity-model, and in-tree CAN-driver behavior. + +#### Scenario: Existing OpenArm blueprints +- **GIVEN** an existing OpenArm blueprint selects the current OpenArm adapter path +- **WHEN** this cleanup is present +- **THEN** the blueprint SHALL continue selecting the `openarm` adapter unless it is explicitly changed +- **AND** existing stable OpenArm runnable blueprint names SHALL remain stable unless a generated-registry update intentionally changes only binding-backed OpenArm RS names. + +#### Scenario: OpenArm source-level stability +- **GIVEN** the original OpenArm adapter is the stable hardware path +- **WHEN** the binding-backed OpenArm RS path is renamed or refactored +- **THEN** DimOS SHALL NOT require the original OpenArm adapter to inherit binding-backed or shared Damiao runtime behavior +- **AND** any source-level OpenArm adapter changes MUST be limited to preserving existing behavior or undoing unintended refactor drift. + +### Requirement: OpenArm hardware safety preservation + +OpenArm adapter behavior SHALL remain at least as safe as the pre-cleanup behavior for enablement, command writes, gravity compensation, and shutdown. + +#### Scenario: Stopping or disconnecting OpenArm hardware +- **GIVEN** OpenArm motors are enabled through DimOS +- **WHEN** the adapter is stopped or disconnected +- **THEN** DimOS SHALL attempt to disable or stop commanding the motors through the adapter +- **AND** the cleanup SHALL NOT introduce a continued background command loop after disconnect. + +#### Scenario: OpenArm gravity compensation +- **GIVEN** OpenArm gravity compensation is enabled +- **WHEN** DimOS computes and sends supported OpenArm commands +- **THEN** gravity feed-forward SHALL use the OpenArm-specific model and current measured joint state +- **AND** invalid or stale state SHALL prevent unsafe gravity-compensation commands from being sent. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-rs-adapter-selection/spec.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-rs-adapter-selection/spec.md new file mode 100644 index 0000000000..aa2e7d8b4b --- /dev/null +++ b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-rs-adapter-selection/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Explicit OpenArm RS adapter selection + +DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. + +#### Scenario: Selecting the Rust-backed OpenArm path +- **GIVEN** a hardware configuration selects the binding-backed OpenArm adapter +- **WHEN** DimOS initializes manipulator hardware through the adapter registry +- **THEN** the configuration SHALL select the adapter using the `openarm_rs` key +- **AND** the selected adapter SHALL represent OpenArm hardware rather than a generic Damiao arm. + +#### Scenario: Binding unavailable for OpenArm RS +- **GIVEN** the `can_motor_control` binding is not importable in the runtime environment +- **WHEN** a user selects the `openarm_rs` adapter +- **THEN** DimOS SHALL fail with a clear selected-adapter missing-binding error +- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding. + +### Requirement: OpenArm RS blueprint naming + +DimOS SHALL expose binding-backed OpenArm runnable blueprints with names that identify the OpenArm RS path. + +#### Scenario: Listing binding-backed OpenArm blueprints +- **GIVEN** binding-backed OpenArm blueprints are exported +- **WHEN** a user lists or runs OpenArm blueprints through the DimOS CLI +- **THEN** runnable names SHALL use `openarm-rs` wording instead of `dm-motor-openarm` wording +- **AND** documentation SHALL describe those blueprints as opt-in alternatives to the stable `openarm` path. + +### Requirement: OpenArm RS safety staging + +DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. + +#### Scenario: Validating before real hardware operation +- **GIVEN** a user wants to run OpenArm hardware through the `openarm_rs` path +- **WHEN** they follow DimOS bring-up guidance +- **THEN** the guidance SHALL start with binding availability and mock or virtual CAN validation +- **AND** it SHALL treat real hardware gravity compensation and trajectory execution as later staged QA steps. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/tasks.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/tasks.md new file mode 100644 index 0000000000..6a3396826b --- /dev/null +++ b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/tasks.md @@ -0,0 +1,35 @@ +## 1. Adapter Boundary Cleanup + +- [x] 1.1 Restore or preserve `dimos/hardware/manipulators/openarm/adapter.py` as the stable standalone OpenArm adapter with `adapter_type="openarm"` unchanged. +- [x] 1.2 Create `dimos/hardware/manipulators/openarm_rs/` from the binding-backed adapter path and export an OpenArm-specific adapter class. +- [x] 1.3 Register the binding-backed adapter under `openarm_rs` and update selected-adapter missing-binding messages to reference `adapter_type="openarm_rs"`. +- [x] 1.4 Remove or reject generic non-OpenArm construction paths from the renamed binding-backed adapter so OpenArm defaults are not treated as generic Damiao defaults. +- [x] 1.5 Keep lazy `can_motor_control` imports scoped to OpenArm RS selection or connection so unrelated adapter discovery remains healthy. + +## 2. Damiao Metadata Readability + +- [x] 2.1 Add class and function docstrings to `dimos/hardware/manipulators/damiao/specs.py` explaining motor metadata, receive-ID defaulting, arm metadata, validation, and coercion behavior. +- [x] 2.2 Confirm Damiao metadata validation behavior remains unchanged after docstring-only edits. + +## 3. Blueprints, Registry, and Tests + +- [x] 3.1 Rename binding-backed OpenArm blueprint variables and runnable names from `dm_motor_openarm` / `dm-motor-openarm` wording to `openarm_rs` / `openarm-rs` wording. +- [x] 3.2 Update OpenArm RS tests to assert the `openarm_rs` registry key, class/package names, missing-binding message, OpenArm-only scope, and mock/vcan lifecycle behavior. +- [x] 3.3 Update OpenArm adapter compatibility tests to prove the stable `openarm` key and observable OpenArm behavior remain unchanged. +- [x] 3.4 Regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py` if runnable blueprint exports change. + +## 4. Documentation + +- [x] 4.1 Update `docs/capabilities/manipulation/openarm_integration.md` to describe `openarm` and `openarm_rs` adapter paths and staged OpenArm RS validation. +- [x] 4.2 Replace user-facing `dm_motor_arm` / `coordinator-dm-motor-openarm*` references with `openarm_rs` / renamed blueprint references where this cleanup changes the surface. +- [x] 4.3 Update contributor or coding-agent docs only if they mention the old binding-backed adapter key or recommend editing the original OpenArm adapter for OpenArm RS work. + +## 5. Verification + +- [x] 5.1 Run `openspec validate cleanup-openarm-rs-adapter`. +- [x] 5.2 Run focused tests for `dimos/hardware/manipulators/openarm/`, `dimos/hardware/manipulators/openarm_rs/`, and `dimos/hardware/manipulators/damiao/`. +- [x] 5.3 Run `pytest dimos/robot/test_all_blueprints_generation.py` when blueprint names or exports change, and keep `dimos/robot/all_blueprints.py` current. +- [x] 5.4 Run docs validation for changed docs, including `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable blocks are added or changed. +- [x] 5.5 Manually QA adapter registry discovery through the library surface by confirming `openarm` and `openarm_rs` keys are available and `dm_motor_arm` is absent or intentionally handled. +- [x] 5.6 Manually QA OpenArm RS mock/vcan operation through the library or coordinator surface: connect, enable, read a coherent state snapshot, write a supported command, stop, and disconnect. +- [ ] 5.7 Manually QA real OpenArm RS hardware only after mock/vcan validation: one-arm enable/read, low-rate hold or gravity compensation, safe stop, and optional trajectory-control validation. diff --git a/openspec/changes/refactor-damiao-arm-adapters/.openspec.yaml b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/.openspec.yaml similarity index 100% rename from openspec/changes/refactor-damiao-arm-adapters/.openspec.yaml rename to openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/.openspec.yaml diff --git a/openspec/changes/refactor-damiao-arm-adapters/design.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/design.md similarity index 100% rename from openspec/changes/refactor-damiao-arm-adapters/design.md rename to openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/design.md diff --git a/openspec/changes/refactor-damiao-arm-adapters/docs.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/docs.md similarity index 100% rename from openspec/changes/refactor-damiao-arm-adapters/docs.md rename to openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/docs.md diff --git a/openspec/changes/refactor-damiao-arm-adapters/proposal.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/proposal.md similarity index 100% rename from openspec/changes/refactor-damiao-arm-adapters/proposal.md rename to openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/proposal.md diff --git a/openspec/changes/refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md similarity index 100% rename from openspec/changes/refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md rename to openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md diff --git a/openspec/changes/refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md similarity index 100% rename from openspec/changes/refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md rename to openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md diff --git a/openspec/changes/refactor-damiao-arm-adapters/tasks.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/tasks.md similarity index 100% rename from openspec/changes/refactor-damiao-arm-adapters/tasks.md rename to openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/tasks.md diff --git a/openspec/specs/damiao-adapter-metadata-docs/spec.md b/openspec/specs/damiao-adapter-metadata-docs/spec.md new file mode 100644 index 0000000000..63ca9ce74c --- /dev/null +++ b/openspec/specs/damiao-adapter-metadata-docs/spec.md @@ -0,0 +1,31 @@ +## Purpose + +Define documentation expectations for Damiao adapter metadata helpers. + +## Requirements + +### Requirement: Readable Damiao metadata documentation + +DimOS SHALL provide readable developer documentation for Damiao adapter metadata helpers so future maintainers can understand motor order, identifiers, limits, gains, and validation behavior. + +#### Scenario: Reading motor metadata helpers +- **GIVEN** a developer opens the Damiao metadata helper module +- **WHEN** they inspect the motor metadata type and receive-ID behavior +- **THEN** the documentation SHALL explain the meaning of joint order, motor type, send ID, receive ID, and default receive-ID derivation +- **AND** it MUST make clear that the metadata is used before hardware commands are sent. + +#### Scenario: Reading arm metadata helpers +- **GIVEN** a developer opens the Damiao arm metadata type +- **WHEN** they inspect arm limits, gains, gravity model fields, bus fields, or validation helpers +- **THEN** the documentation SHALL explain that these values describe explicit adapter-owned hardware metadata +- **AND** it SHALL distinguish metadata coercion and validation from runtime hardware communication. + +### Requirement: Damiao metadata validation readability + +DimOS SHALL keep Damiao metadata validation behavior understandable without requiring readers to infer intent from tests alone. + +#### Scenario: Understanding validation failures +- **GIVEN** an adapter provides duplicate IDs or mismatched vector lengths +- **WHEN** validation rejects the metadata +- **THEN** the helper documentation SHALL make the validation intent clear to developers +- **AND** validation errors MUST remain focused on preventing malformed metadata from reaching hardware command paths. diff --git a/openspec/specs/dm-motor-manipulator-adapters/spec.md b/openspec/specs/dm-motor-manipulator-adapters/spec.md new file mode 100644 index 0000000000..e169d2737a --- /dev/null +++ b/openspec/specs/dm-motor-manipulator-adapters/spec.md @@ -0,0 +1,43 @@ +## Purpose + +Define the narrowed binding-backed OpenArm adapter behavior for the Rust-backed OpenArm RS path. + +## Requirements + +### Requirement: Binding-backed OpenArm adapter scope + +DimOS SHALL narrow the current binding-backed DMMotor/OpenArm behavior into an explicit OpenArm RS adapter path rather than presenting it as a generic DMMotor arm adapter. + +#### Scenario: Selecting the renamed binding-backed adapter +- **GIVEN** a hardware configuration needs the Rust-backed OpenArm binding path +- **WHEN** the configuration selects an adapter type +- **THEN** it SHALL use `openarm_rs` for the binding-backed OpenArm adapter +- **AND** it SHALL NOT rely on `dm_motor_arm` as the documented OpenArm binding-backed adapter key. + +#### Scenario: Avoiding generic Damiao defaults +- **GIVEN** a non-OpenArm Damiao arm needs support in the future +- **WHEN** a developer evaluates the OpenArm RS adapter +- **THEN** DimOS SHALL make clear that OpenArm RS metadata and defaults are OpenArm-specific +- **AND** DimOS MUST require a separate explicit adapter or change before treating those defaults as generic Damiao behavior. + +### Requirement: Binding-backed adapter safety behavior + +DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. + +#### Scenario: Coherent state reads +- **GIVEN** the OpenArm RS adapter is connected through the binding-backed path +- **WHEN** DimOS reads positions, velocities, and efforts during one coordinator cycle +- **THEN** the adapter SHALL provide a coherent state snapshot rather than independently loading the CAN bus for each read +- **AND** stale or malformed state SHALL be surfaced before unsafe commands are sent. + +#### Scenario: Gravity-compensation-only command +- **GIVEN** a user invokes a gravity-compensation-only validation path through the binding-backed adapter +- **WHEN** the adapter sends MIT commands for gravity compensation +- **THEN** the command SHALL use zero position stiffness so the arm remains manually movable +- **AND** the command SHALL use current measured state and configured OpenArm gravity metadata. + +#### Scenario: Missing binding remains selected-adapter scoped +- **GIVEN** the optional binding is not installed +- **WHEN** DimOS discovers manipulator adapters without selecting OpenArm RS +- **THEN** discovery SHALL continue for adapters that do not require the binding +- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path. diff --git a/openspec/specs/openarm-adapter-compatibility/spec.md b/openspec/specs/openarm-adapter-compatibility/spec.md new file mode 100644 index 0000000000..731e82c777 --- /dev/null +++ b/openspec/specs/openarm-adapter-compatibility/spec.md @@ -0,0 +1,43 @@ +## Purpose + +Define compatibility and safety expectations for the stable in-tree OpenArm adapter path. + +## Requirements + +### Requirement: OpenArm adapter compatibility + +DimOS SHALL preserve the existing OpenArm adapter selection and observable behavior while keeping the binding-backed OpenArm RS path separate. + +#### Scenario: Selecting the OpenArm adapter +- **GIVEN** an existing hardware configuration selects the OpenArm adapter +- **WHEN** DimOS initializes the manipulator hardware +- **THEN** DimOS SHALL continue to create an OpenArm-specific adapter through the existing `openarm` adapter selection surface +- **AND** the adapter SHALL preserve OpenArm-specific side, joint, motor, limit, gain, gravity-model, and in-tree CAN-driver behavior. + +#### Scenario: Existing OpenArm blueprints +- **GIVEN** an existing OpenArm blueprint selects the current OpenArm adapter path +- **WHEN** this cleanup is present +- **THEN** the blueprint SHALL continue selecting the `openarm` adapter unless it is explicitly changed +- **AND** existing stable OpenArm runnable blueprint names SHALL remain stable unless a generated-registry update intentionally changes only binding-backed OpenArm RS names. + +#### Scenario: OpenArm source-level stability +- **GIVEN** the original OpenArm adapter is the stable hardware path +- **WHEN** the binding-backed OpenArm RS path is renamed or refactored +- **THEN** DimOS SHALL NOT require the original OpenArm adapter to inherit binding-backed or shared Damiao runtime behavior +- **AND** any source-level OpenArm adapter changes MUST be limited to preserving existing behavior or undoing unintended refactor drift. + +### Requirement: OpenArm hardware safety preservation + +OpenArm adapter behavior SHALL remain at least as safe as the pre-cleanup behavior for enablement, command writes, gravity compensation, and shutdown. + +#### Scenario: Stopping or disconnecting OpenArm hardware +- **GIVEN** OpenArm motors are enabled through DimOS +- **WHEN** the adapter is stopped or disconnected +- **THEN** DimOS SHALL attempt to disable or stop commanding the motors through the adapter +- **AND** the cleanup SHALL NOT introduce a continued background command loop after disconnect. + +#### Scenario: OpenArm gravity compensation +- **GIVEN** OpenArm gravity compensation is enabled +- **WHEN** DimOS computes and sends supported OpenArm commands +- **THEN** gravity feed-forward SHALL use the OpenArm-specific model and current measured joint state +- **AND** invalid or stale state SHALL prevent unsafe gravity-compensation commands from being sent. diff --git a/openspec/specs/openarm-rs-adapter-selection/spec.md b/openspec/specs/openarm-rs-adapter-selection/spec.md new file mode 100644 index 0000000000..b1f678fadf --- /dev/null +++ b/openspec/specs/openarm-rs-adapter-selection/spec.md @@ -0,0 +1,41 @@ +## Purpose + +Define the explicit user-facing selection and safety staging surface for the OpenArm RS adapter. + +## Requirements + +### Requirement: Explicit OpenArm RS adapter selection + +DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. + +#### Scenario: Selecting the Rust-backed OpenArm path +- **GIVEN** a hardware configuration selects the binding-backed OpenArm adapter +- **WHEN** DimOS initializes manipulator hardware through the adapter registry +- **THEN** the configuration SHALL select the adapter using the `openarm_rs` key +- **AND** the selected adapter SHALL represent OpenArm hardware rather than a generic Damiao arm. + +#### Scenario: Binding unavailable for OpenArm RS +- **GIVEN** the `can_motor_control` binding is not importable in the runtime environment +- **WHEN** a user selects the `openarm_rs` adapter +- **THEN** DimOS SHALL fail with a clear selected-adapter missing-binding error +- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding. + +### Requirement: OpenArm RS blueprint naming + +DimOS SHALL expose binding-backed OpenArm runnable blueprints with names that identify the OpenArm RS path. + +#### Scenario: Listing binding-backed OpenArm blueprints +- **GIVEN** binding-backed OpenArm blueprints are exported +- **WHEN** a user lists or runs OpenArm blueprints through the DimOS CLI +- **THEN** runnable names SHALL use `openarm-rs` wording instead of `dm-motor-openarm` wording +- **AND** documentation SHALL describe those blueprints as opt-in alternatives to the stable `openarm` path. + +### Requirement: OpenArm RS safety staging + +DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. + +#### Scenario: Validating before real hardware operation +- **GIVEN** a user wants to run OpenArm hardware through the `openarm_rs` path +- **WHEN** they follow DimOS bring-up guidance +- **THEN** the guidance SHALL start with binding availability and mock or virtual CAN validation +- **AND** it SHALL treat real hardware gravity compensation and trajectory execution as later staged QA steps. From 892e0f29633fec0a9f649a669ebbaeb28fb31af6 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 17:21:53 -0700 Subject: [PATCH 009/110] refactor: type damiao binding adapter --- .../hardware/manipulators/damiao/__init__.py | 12 +- .../manipulators/damiao/base_adapter.py | 414 +++++++++++++++++- .../manipulators/openarm_rs/adapter.py | 377 ++-------------- .../manipulators/openarm_rs/test_adapter.py | 74 +++- stubs/can_motor_control/__init__.pyi | 45 ++ stubs/can_motor_control/damiao.pyi | 7 + 6 files changed, 550 insertions(+), 379 deletions(-) create mode 100644 stubs/can_motor_control/__init__.pyi create mode 100644 stubs/can_motor_control/damiao.pyi diff --git a/dimos/hardware/manipulators/damiao/__init__.py b/dimos/hardware/manipulators/damiao/__init__.py index 6531e20677..99975f5fba 100644 --- a/dimos/hardware/manipulators/damiao/__init__.py +++ b/dimos/hardware/manipulators/damiao/__init__.py @@ -12,7 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase +from dimos.hardware.manipulators.damiao.base_adapter import ( + DamiaoArmAdapterBase, + DamiaoBindingUnavailableError, +) from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec -__all__ = ["DamiaoArmAdapterBase", "DamiaoArmSpec", "DamiaoMotorSpec"] +__all__ = [ + "DamiaoArmAdapterBase", + "DamiaoArmSpec", + "DamiaoBindingUnavailableError", + "DamiaoMotorSpec", +] diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index 89e60840cb..6e70db8ea5 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -16,23 +16,162 @@ import importlib from pathlib import Path -from typing import Any, Protocol, cast +import time +from typing import Protocol, cast import numpy as np +from numpy.typing import NDArray from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec from dimos.hardware.manipulators.spec import ControlMode, JointLimits, ManipulatorInfo +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +FloatArray = NDArray[np.float64] + + +class _SupportsInt(Protocol): + def __int__(self) -> int: ... + + +class _PinocchioModel(Protocol): + def createData(self) -> object: ... class _PinocchioModule(Protocol): - def buildModelFromUrdf(self, filename: str) -> Any: ... + def buildModelFromUrdf(self, filename: str) -> _PinocchioModel: ... + + def computeGeneralizedGravity( + self, model: _PinocchioModel, data: object, q: FloatArray + ) -> FloatArray: ... + + +class _Motor(Protocol): + fault: int | None + + +class _Arm(Protocol): + def __len__(self) -> int: ... + + def __getitem__(self, name: str) -> _Motor: ... + + def refresh(self) -> None: ... + + def positions(self) -> FloatArray: ... + + def velocities(self) -> FloatArray: ... + + def torques(self) -> FloatArray: ... + + def mit_control(self, cmds: FloatArray) -> None: ... + + +class _Robot(Protocol): + def __getitem__(self, name: str) -> _Arm: ... + + def connect(self) -> None: ... + + def enable(self) -> None: ... + + def disable(self) -> None: ... + + def tick(self, per_bus_deadline_us: int) -> None: ... + + +class _RobotBuilder(Protocol): + def add_bus(self, name: str, transport: object, codec: object) -> _RobotBuilder: ... + + def add_arm(self, name: str, *, bus: str, motors: list[object]) -> _RobotBuilder: ... + + def build(self) -> _Robot: ... + + +class _RobotFactory(Protocol): + def builder(self) -> _RobotBuilder: ... + + def from_config(self, path: str) -> _Robot: ... + + +class _MotorSpecFactory(Protocol): + def __call__(self, name: str, type: object, send_id: int, recv_id: int) -> object: ... + + +class _MockCanBusFactory(Protocol): + def __call__(self, name: str, fd: bool = False) -> object: ... + + def new_fd(self, name: str) -> object: ... + + +class _SocketCanBusFactory(Protocol): + def __call__(self, interface: str, fd: bool = False) -> object: ... + + +class _CanMotorControlModule(Protocol): + Robot: _RobotFactory + MotorSpec: _MotorSpecFactory + MockCanBus: _MockCanBusFactory + SocketCanBus: _SocketCanBusFactory + + +class _DamiaoModule(Protocol): + MotorType: object + DamiaoCodec: type[object] + + +class DamiaoBindingUnavailableError(RuntimeError): + pass + - def computeGeneralizedGravity(self, model: Any, data: Any, q: np.ndarray) -> Any: ... +def _as_object(value: object) -> object: + return value + + +def _load_can_motor_control( + *, + adapter_type: str, + error_type: type[RuntimeError] = DamiaoBindingUnavailableError, +) -> tuple[_CanMotorControlModule, _DamiaoModule]: + try: + import can_motor_control + from can_motor_control import damiao + except ImportError as exc: + raise error_type( + f"The selected '{adapter_type}' adapter requires the Rust-backed " + "can-motor-control Python binding in the active environment. Install " + f"dimos[manipulation] before selecting adapter_type='{adapter_type}'." + ) from exc + return cast("_CanMotorControlModule", _as_object(can_motor_control)), cast( + "_DamiaoModule", _as_object(damiao) + ) + + +def _resolve_motor_type(damiao: _DamiaoModule, motor_type: object) -> object: + if isinstance(motor_type, str): + try: + return cast("object", getattr(damiao.MotorType, motor_type)) + except AttributeError as exc: + raise ValueError(f"Unknown Damiao motor type {motor_type!r}") from exc + if not isinstance(motor_type, int): + return motor_type + for name in dir(damiao.MotorType): + if name.startswith("_"): + continue + candidate = cast("object", getattr(damiao.MotorType, name)) + try: + candidate_value = int(cast("_SupportsInt", candidate)) + except (TypeError, ValueError): + continue + if candidate_value == motor_type: + return candidate + raise ValueError(f"Unknown Damiao motor type value {motor_type!r}") class DamiaoArmAdapterBase: """Shared DimOS adapter behavior for Damiao-based manipulators.""" + _adapter_type: str = "damiao" + _binding_error_type: type[RuntimeError] = DamiaoBindingUnavailableError _supported_control_modes: tuple[ControlMode, ...] = ( ControlMode.POSITION, ControlMode.SERVO_POSITION, @@ -51,6 +190,11 @@ def __init__( gravity_model_path: str | Path | None = None, gravity_torque_limits: list[float] | tuple[float, ...] | None = None, supported_control_modes: tuple[ControlMode, ...] | None = None, + address: str | Path | None = "can0", + config_path: str | Path | None = None, + use_mock_bus: bool = False, + tick_deadline_us: int = 1_000, + state_cache_ttl_s: float = 0.002, ) -> None: arm_spec.validate() if dof is not None and dof != arm_spec.dof: @@ -91,8 +235,23 @@ def __init__( self._control_mode = ControlMode.POSITION self._enabled = False self._last_positions: list[float] | None = None - self._pin_model: Any = None - self._pin_data: Any = None + self._pin_model: _PinocchioModel | None = None + self._pin_data: object | None = None + self._address = str(address) if address is not None else "can0" + self._config_path = str(config_path) if config_path is not None else None + self._arm_name = arm_spec.arm_name + self._bus_name = arm_spec.bus_name + self._fd = arm_spec.fd + self._use_mock_bus = use_mock_bus + self._tick_deadline_us = tick_deadline_us + self._state_cache_ttl_s = state_cache_ttl_s + self._can_motor_control: _CanMotorControlModule | None = None + self._damiao: _DamiaoModule | None = None + self._robot: _Robot | None = None + self._arm: _Arm | None = None + self._connected = False + self._state_cache: tuple[list[float], list[float], list[float]] | None = None + self._state_cache_time = 0.0 def _validate_length(self, name: str, values: list[float]) -> None: if len(values) != self._dof: @@ -163,6 +322,249 @@ def write_gripper_position(self, position: float) -> bool: def read_force_torque(self) -> list[float] | None: return None + def connect(self) -> bool: + try: + self._can_motor_control, self._damiao = _load_can_motor_control( + adapter_type=self._adapter_type, + error_type=self._binding_error_type, + ) + self._robot = self._build_robot() + self._robot.connect() + self._arm = self._robot[self._arm_name] + if len(self._arm) != self._dof: + raise RuntimeError( + f"can_motor_control arm group {self._arm_name!r} has {len(self._arm)} joints, " + f"expected {self._dof}" + ) + self._load_gravity_model() + self._connected = True + self.refresh_state(force=True) + except self._binding_error_type: + raise + except Exception as exc: + logger.error( + f"{type(self).__name__} {self._hardware_id}@{self._address} connect failed: {exc}" + ) + self._robot = None + self._arm = None + self._connected = False + return False + return True + + def _build_robot(self) -> _Robot: + assert self._can_motor_control is not None + assert self._damiao is not None + if self._config_path is not None: + return self._can_motor_control.Robot.from_config(self._config_path) + transport = ( + self._can_motor_control.MockCanBus.new_fd(self._address) + if self._use_mock_bus and self._fd + else self._can_motor_control.MockCanBus(self._address) + if self._use_mock_bus + else self._can_motor_control.SocketCanBus(self._address, fd=self._fd) + ) + codec = self._damiao.DamiaoCodec() + binding_specs = [ + self._can_motor_control.MotorSpec( + spec.name, + _resolve_motor_type(self._damiao, spec.type), + spec.send_id, + spec.effective_recv_id, + ) + for spec in self._motor_specs + ] + return ( + self._can_motor_control.Robot.builder() + .add_bus(self._bus_name, transport, codec) + .add_arm(self._arm_name, bus=self._bus_name, motors=binding_specs) + .build() + ) + + def disconnect(self) -> None: + if self._robot is not None: + try: + self._robot.disable() + except Exception as exc: + logger.warning( + f"{type(self).__name__} {self._hardware_id} disable on disconnect failed: {exc}" + ) + self._enabled = False + self._connected = False + self._robot = None + self._arm = None + self._state_cache = None + + def is_connected(self) -> bool: + return self._connected + + def refresh_state(self, *, force: bool = False) -> tuple[list[float], list[float], list[float]]: + if self._robot is None or self._arm is None: + raise RuntimeError(f"{type(self).__name__} is not connected") + now = time.monotonic() + if ( + not force + and self._state_cache is not None + and now - self._state_cache_time <= self._state_cache_ttl_s + ): + return self._state_cache + self._arm.refresh() + self._robot.tick(self._tick_deadline_us) + state = ( + [float(value) for value in self._arm.positions().astype(np.float64)], + [float(value) for value in self._arm.velocities().astype(np.float64)], + [float(value) for value in self._arm.torques().astype(np.float64)], + ) + if any(len(values) != self._dof for values in state): + raise RuntimeError("can_motor_control state length does not match configured DOF") + self._state_cache = state + self._state_cache_time = time.monotonic() + self._last_positions = list(state[0]) + return state + + def read_joint_positions(self) -> list[float]: + return list(self.refresh_state()[0]) + + def read_joint_velocities(self) -> list[float]: + return list(self.refresh_state()[1]) + + def read_joint_efforts(self) -> list[float]: + return list(self.refresh_state()[2]) + + def read_state(self) -> dict[str, int]: + return { + "state": 1 if self._enabled else 0, + "mode": list(ControlMode).index(self._control_mode), + } + + def read_error(self) -> tuple[int, str]: + if self._arm is None: + return 0, "" + faults = [] + for spec in self._motor_specs: + fault = getattr(self._arm[spec.name], "fault", None) + if fault is not None: + faults.append(f"{spec.name}: {fault}") + return (0, "") if not faults else (1, "; ".join(faults)) + + def write_joint_positions(self, positions: list[float], velocity: float = 1.0) -> bool: + if ( + self._arm is None + or self._robot is None + or not self._enabled + or len(positions) != self._dof + ): + return False + velocity = max(0.0, min(1.0, velocity)) + if self._gravity_comp: + try: + tau = self.compute_gravity_torques(self.read_joint_positions()) + except RuntimeError: + tau = self._zero_vector() + else: + tau = self._zero_vector() + return self.write_mit_commands( + q=list(positions), + dq=self._zero_vector(), + kp=[kp * velocity for kp in self._kp], + kd=list(self._kd), + tau=tau, + ) + + def write_joint_velocities(self, velocities: list[float]) -> bool: + return False + + def write_joint_torques(self, efforts: list[float]) -> bool: + if ( + self._arm is None + or self._robot is None + or not self._enabled + or len(efforts) != self._dof + ): + return False + q = ( + self._last_positions + if self._last_positions is not None + else self.read_joint_positions() + ) + return self.write_mit_commands( + q=q, dq=self._zero_vector(), kp=self._zero_vector(), kd=self._zero_vector(), tau=efforts + ) + + def write_gravity_compensation(self, damping: float | list[float] = 0.0) -> bool: + try: + q, dq, _ = self.refresh_state(force=True) + tau = self.compute_gravity_torques(q) + except Exception as exc: + logger.warning( + f"Skipping {type(self).__name__} gravity compensation due to invalid state: {exc}" + ) + return False + kd = [float(damping)] * self._dof if isinstance(damping, int | float) else list(damping) + return self.write_mit_commands(q=q, dq=dq, kp=self._zero_vector(), kd=kd, tau=tau) + + def write_mit_commands( + self, *, q: list[float], dq: list[float], kp: list[float], kd: list[float], tau: list[float] + ) -> bool: + if self._arm is None or self._robot is None or not self._enabled: + return False + rows = self._mit_command_rows(q=q, dq=dq, kp=kp, kd=kd, tau=tau) + self._arm.mit_control( + np.array([(row[2], row[3], row[0], row[1], row[4]) for row in rows], dtype=np.float64) + ) + self._robot.tick(self._tick_deadline_us) + self._state_cache = None + self._last_positions = list(q) + self._control_mode = ( + ControlMode.TORQUE if all(k == 0.0 for k in kp) else ControlMode.POSITION + ) + return True + + def write_stop(self) -> bool: + if self._arm is None or self._robot is None: + return False + if self._gravity_comp and self._enabled: + try: + q_now = self.read_joint_positions() + except RuntimeError: + return False + return self.write_mit_commands( + q=q_now, + dq=self._zero_vector(), + kp=list(self._kp), + kd=list(self._kd), + tau=self.compute_gravity_torques(q_now), + ) + try: + self._robot.disable() + except Exception as exc: + logger.warning(f"{type(self).__name__} {self._hardware_id} stop disable failed: {exc}") + return False + self._enabled = False + return True + + def write_enable(self, enable: bool) -> bool: + if self._robot is None: + return False + try: + self._robot.enable() if enable else self._robot.disable() + except Exception as exc: + logger.error(f"{type(self).__name__} {self._hardware_id} enable={enable} failed: {exc}") + return False + self._enabled = enable + return True + + def write_clear_errors(self) -> bool: + if self._robot is None: + return False + try: + self._robot.disable() + self._robot.enable() + except Exception as exc: + logger.error(f"{type(self).__name__} {self._hardware_id} clear errors failed: {exc}") + return False + self._enabled = True + return True + def _load_gravity_model(self) -> None: if not self._gravity_comp or self._gravity_model_path is None: return @@ -191,4 +593,4 @@ def compute_gravity_torques(self, q: list[float]) -> list[float]: ] -__all__ = ["DamiaoArmAdapterBase"] +__all__ = ["DamiaoArmAdapterBase", "DamiaoBindingUnavailableError"] diff --git a/dimos/hardware/manipulators/openarm_rs/adapter.py b/dimos/hardware/manipulators/openarm_rs/adapter.py index 9c19041a78..8017eae80f 100644 --- a/dimos/hardware/manipulators/openarm_rs/adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/adapter.py @@ -14,66 +14,28 @@ from __future__ import annotations -import importlib from pathlib import Path -import time -from types import ModuleType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -import numpy as np - -from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase +from dimos.hardware.manipulators.damiao.base_adapter import ( + DamiaoArmAdapterBase, + DamiaoBindingUnavailableError, +) from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec -from dimos.hardware.manipulators.spec import ControlMode -from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: from dimos.hardware.manipulators.registry import AdapterRegistry -logger = setup_logger() - OpenArmRSMotorSpecConfig = DamiaoMotorSpec -class OpenArmRSBindingUnavailableError(RuntimeError): +class OpenArmRSBindingUnavailableError(DamiaoBindingUnavailableError): pass -def _load_can_motor_control() -> tuple[ModuleType, ModuleType]: - try: - can_motor_control = importlib.import_module("can_motor_control") - damiao = importlib.import_module("can_motor_control.damiao") - except ImportError as exc: - raise OpenArmRSBindingUnavailableError( - "The selected 'openarm_rs' adapter requires the Rust-backed " - "can-motor-control Python binding in the active environment. Install " - "dimos[manipulation] before selecting adapter_type='openarm_rs'." - ) from exc - return can_motor_control, damiao - - -def _resolve_motor_type(damiao: ModuleType, motor_type: str | int | Any) -> Any: - if isinstance(motor_type, str): - try: - return getattr(damiao.MotorType, motor_type) - except AttributeError as exc: - raise ValueError(f"Unknown Damiao motor type {motor_type!r}") from exc - if not isinstance(motor_type, int): - return motor_type - for name in dir(damiao.MotorType): - if name.startswith("_"): - continue - candidate = getattr(damiao.MotorType, name) - try: - candidate_value = int(candidate) - except (TypeError, ValueError): - continue - if candidate_value == motor_type: - return candidate - raise ValueError(f"Unknown Damiao motor type value {motor_type!r}") - - class OpenArmRSAdapter(DamiaoArmAdapterBase): + _adapter_type: str = "openarm_rs" + _binding_error_type: type[RuntimeError] = OpenArmRSBindingUnavailableError _DEFAULT_OPENARM_MOTORS: tuple[DamiaoMotorSpec, ...] = ( DamiaoMotorSpec("joint1", "DM8006", 0x01, 0x11), DamiaoMotorSpec("joint2", "DM8006", 0x02, 0x12), @@ -101,7 +63,7 @@ def __init__( fd: bool | None = None, canfd: bool = True, use_mock_bus: bool = False, - motor_specs: list[dict[str, Any] | DamiaoMotorSpec] | None = None, + motor_specs: list[dict[str, object] | DamiaoMotorSpec] | None = None, position_lower: list[float] | None = None, position_upper: list[float] | None = None, velocity_max: list[float] | None = None, @@ -112,25 +74,26 @@ def __init__( state_cache_ttl_s: float = 0.002, gravity_model_path: str | Path | None = None, gravity_torque_limits: list[float] | None = None, - **_: Any, + **_: object, ) -> None: - specs = self._openarm_motor_specs( - dof=dof, - motor_specs=motor_specs, - position_lower=position_lower, - position_upper=position_upper, - velocity_max=velocity_max, - ) + if dof != len(self._DEFAULT_OPENARM_MOTORS): + raise ValueError(f"OpenArmRSAdapter only supports 7 DOF (got {dof})") + if motor_specs is not None: + raise ValueError("openarm_rs is OpenArm-only and does not accept custom motor_specs") + if position_lower is not None or position_upper is not None or velocity_max is not None: + raise ValueError( + "openarm_rs uses fixed OpenArm limits; custom limits require a separate adapter" + ) arm_spec = DamiaoArmSpec.from_values( name="openarm_rs", vendor="Enactic", model="OpenArm RS v10", - motors=tuple(specs), + motors=self._DEFAULT_OPENARM_MOTORS, position_lower=self._DEFAULT_POSITION_LOWER, position_upper=self._DEFAULT_POSITION_UPPER, velocity_max=self._DEFAULT_VELOCITY_MAX, - kp=kp if kp is not None else self._DEFAULT_KP[:dof], - kd=kd if kd is not None else self._DEFAULT_KD[:dof], + kp=kp if kp is not None else self._DEFAULT_KP, + kd=kd if kd is not None else self._DEFAULT_KD, gravity_model_path=gravity_model_path, gravity_torque_limits=gravity_torque_limits, bus_name=bus_name, @@ -139,303 +102,15 @@ def __init__( ) super().__init__( arm_spec=arm_spec, + address=address, hardware_id=hardware_id, + config_path=config_path, + use_mock_bus=use_mock_bus, gravity_comp=gravity_comp, - supported_control_modes=( - ControlMode.POSITION, - ControlMode.SERVO_POSITION, - ControlMode.TORQUE, - ), - ) - self._address = str(address) if address is not None else "can0" - self._config_path = str(config_path) if config_path is not None else None - self._arm_name = arm_name - self._bus_name = bus_name - self._fd = canfd if fd is None else fd - self._use_mock_bus = use_mock_bus - self._tick_deadline_us = tick_deadline_us - self._state_cache_ttl_s = state_cache_ttl_s - - self._can_motor_control: ModuleType | None = None - self._damiao: ModuleType | None = None - self._robot: Any = None - self._arm: Any = None - self._connected = False - self._state_cache: tuple[list[float], list[float], list[float]] | None = None - self._state_cache_time = 0.0 - - @classmethod - def _openarm_motor_specs( - cls, - *, - dof: int, - motor_specs: list[dict[str, Any] | DamiaoMotorSpec] | None, - position_lower: list[float] | None, - position_upper: list[float] | None, - velocity_max: list[float] | None, - ) -> list[DamiaoMotorSpec]: - if dof != len(cls._DEFAULT_OPENARM_MOTORS): - raise ValueError(f"OpenArmRSAdapter only supports 7 DOF (got {dof})") - if motor_specs is not None: - raise ValueError("openarm_rs is OpenArm-only and does not accept custom motor_specs") - if position_lower is not None or position_upper is not None or velocity_max is not None: - raise ValueError( - "openarm_rs uses fixed OpenArm limits; custom limits require a separate adapter" - ) - return list(cls._DEFAULT_OPENARM_MOTORS) - - def connect(self) -> bool: - try: - self._can_motor_control, self._damiao = _load_can_motor_control() - self._robot = self._build_robot() - self._robot.connect() - self._arm = self._robot[self._arm_name] - if len(self._arm) != self._dof: - raise RuntimeError( - f"can_motor_control arm group {self._arm_name!r} has {len(self._arm)} joints, " - f"expected {self._dof}" - ) - self._load_gravity_model() - self._connected = True - self.refresh_state(force=True) - except OpenArmRSBindingUnavailableError: - raise - except Exception as exc: - logger.error( - f"OpenArmRSAdapter {self._hardware_id}@{self._address} connect failed: {exc}" - ) - self._robot = None - self._arm = None - self._connected = False - return False - return True - - def _build_robot(self) -> Any: - assert self._can_motor_control is not None - assert self._damiao is not None - if self._config_path is not None: - return self._can_motor_control.Robot.from_config(self._config_path) - - transport = ( - self._can_motor_control.MockCanBus.new_fd(self._address) - if self._use_mock_bus and self._fd - else self._can_motor_control.MockCanBus(self._address) - if self._use_mock_bus - else self._can_motor_control.SocketCanBus(self._address, fd=self._fd) - ) - codec = self._damiao.DamiaoCodec() - binding_specs = [ - self._can_motor_control.MotorSpec( - spec.name, - _resolve_motor_type(self._damiao, spec.type), - spec.send_id, - spec.effective_recv_id, - ) - for spec in self._motor_specs - ] - return ( - self._can_motor_control.Robot.builder() - .add_bus(self._bus_name, transport, codec) - .add_arm(self._arm_name, bus=self._bus_name, motors=binding_specs) - .build() - ) - - def disconnect(self) -> None: - if self._robot is not None: - try: - self._robot.disable() - except Exception as exc: - logger.warning( - f"OpenArmRSAdapter {self._hardware_id} disable on disconnect failed: {exc}" - ) - self._enabled = False - self._connected = False - self._robot = None - self._arm = None - self._state_cache = None - - def is_connected(self) -> bool: - return self._connected - - def refresh_state(self, *, force: bool = False) -> tuple[list[float], list[float], list[float]]: - if self._robot is None or self._arm is None: - raise RuntimeError("OpenArmRSAdapter is not connected") - now = time.monotonic() - if ( - not force - and self._state_cache is not None - and now - self._state_cache_time <= self._state_cache_ttl_s - ): - return self._state_cache - self._arm.refresh() - self._robot.tick(self._tick_deadline_us) - state = ( - self._arm.positions().astype(float).tolist(), - self._arm.velocities().astype(float).tolist(), - self._arm.torques().astype(float).tolist(), - ) - if any(len(values) != self._dof for values in state): - raise RuntimeError("can_motor_control state length does not match configured DOF") - self._state_cache = state - self._state_cache_time = time.monotonic() - self._last_positions = list(state[0]) - return state - - def read_joint_positions(self) -> list[float]: - return list(self.refresh_state()[0]) - - def read_joint_velocities(self) -> list[float]: - return list(self.refresh_state()[1]) - - def read_joint_efforts(self) -> list[float]: - return list(self.refresh_state()[2]) - - def read_state(self) -> dict[str, int]: - return { - "state": 1 if self._enabled else 0, - "mode": list(ControlMode).index(self._control_mode), - } - - def read_error(self) -> tuple[int, str]: - if self._arm is None: - return 0, "" - faults = [] - for spec in self._motor_specs: - motor = self._arm[spec.name] - fault = getattr(motor, "fault", None) - if fault is not None: - faults.append(f"{spec.name}: {fault}") - if not faults: - return 0, "" - return 1, "; ".join(faults) - - def write_joint_positions(self, positions: list[float], velocity: float = 1.0) -> bool: - if self._arm is None or self._robot is None or not self._enabled: - return False - if len(positions) != self._dof: - return False - velocity = max(0.0, min(1.0, velocity)) - if self._gravity_comp: - try: - q_current = self.read_joint_positions() - tau = self.compute_gravity_torques(q_current) - except RuntimeError: - tau = self._zero_vector() - else: - tau = self._zero_vector() - return self.write_mit_commands( - q=list(positions), - dq=self._zero_vector(), - kp=[kp * velocity for kp in self._kp], - kd=list(self._kd), - tau=tau, + tick_deadline_us=tick_deadline_us, + state_cache_ttl_s=state_cache_ttl_s, ) - def write_joint_velocities(self, velocities: list[float]) -> bool: - return False - - def write_joint_torques(self, efforts: list[float]) -> bool: - if self._arm is None or self._robot is None or not self._enabled: - return False - if len(efforts) != self._dof: - return False - q = ( - self._last_positions - if self._last_positions is not None - else self.read_joint_positions() - ) - return self.write_mit_commands( - q=q, - dq=self._zero_vector(), - kp=self._zero_vector(), - kd=self._zero_vector(), - tau=efforts, - ) - - def write_gravity_compensation(self, damping: float | list[float] = 0.0) -> bool: - try: - q, dq, _ = self.refresh_state(force=True) - tau = self.compute_gravity_torques(q) - except Exception as exc: - logger.warning(f"Skipping OpenArm RS gravity compensation due to invalid state: {exc}") - return False - kd = [float(damping)] * self._dof if isinstance(damping, int | float) else list(damping) - return self.write_mit_commands(q=q, dq=dq, kp=self._zero_vector(), kd=kd, tau=tau) - - def write_mit_commands( - self, - *, - q: list[float], - dq: list[float], - kp: list[float], - kd: list[float], - tau: list[float], - ) -> bool: - if self._arm is None or self._robot is None or not self._enabled: - return False - rows = self._mit_command_rows(q=q, dq=dq, kp=kp, kd=kd, tau=tau) - cmds = np.array( - [(row[2], row[3], row[0], row[1], row[4]) for row in rows], dtype=np.float64 - ) - self._arm.mit_control(cmds) - self._robot.tick(self._tick_deadline_us) - self._state_cache = None - self._last_positions = list(q) - self._control_mode = ( - ControlMode.TORQUE if all(k == 0.0 for k in kp) else ControlMode.POSITION - ) - return True - - def write_stop(self) -> bool: - if self._arm is None or self._robot is None: - return False - if self._gravity_comp and self._enabled: - try: - q_now = self.read_joint_positions() - except RuntimeError: - return False - tau = self.compute_gravity_torques(q_now) - return self.write_mit_commands( - q=q_now, - dq=self._zero_vector(), - kp=list(self._kp), - kd=list(self._kd), - tau=tau, - ) - try: - self._robot.disable() - except Exception as exc: - logger.warning(f"OpenArmRSAdapter {self._hardware_id} stop disable failed: {exc}") - return False - self._enabled = False - return True - - def write_enable(self, enable: bool) -> bool: - if self._robot is None: - return False - try: - if enable: - self._robot.enable() - else: - self._robot.disable() - except Exception as exc: - logger.error(f"OpenArmRSAdapter {self._hardware_id} enable={enable} failed: {exc}") - return False - self._enabled = enable - return True - - def write_clear_errors(self) -> bool: - if self._robot is None: - return False - try: - self._robot.disable() - self._robot.enable() - except Exception as exc: - logger.error(f"OpenArmRSAdapter {self._hardware_id} clear errors failed: {exc}") - return False - self._enabled = True - return True - def register(registry: AdapterRegistry) -> None: registry.register("openarm_rs", OpenArmRSAdapter) diff --git a/dimos/hardware/manipulators/openarm_rs/test_adapter.py b/dimos/hardware/manipulators/openarm_rs/test_adapter.py index d2e52dbf20..af8faeab74 100644 --- a/dimos/hardware/manipulators/openarm_rs/test_adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/test_adapter.py @@ -14,8 +14,10 @@ from __future__ import annotations +import builtins +from collections.abc import Mapping from enum import IntEnum -import importlib +import sys from types import ModuleType from unittest.mock import MagicMock @@ -177,8 +179,9 @@ def __init__(self, interface: str, fd: bool = False) -> None: def fake_can_motor_control(monkeypatch: pytest.MonkeyPatch) -> None: fake_dm = FakeDMControl("can_motor_control") fake_damiao = FakeDamiao("can_motor_control.damiao") - monkeypatch.setitem(__import__("sys").modules, "can_motor_control", fake_dm) - monkeypatch.setitem(__import__("sys").modules, "can_motor_control.damiao", fake_damiao) + monkeypatch.setattr(fake_dm, "damiao", fake_damiao, raising=False) + monkeypatch.setitem(sys.modules, "can_motor_control", fake_dm) + monkeypatch.setitem(sys.modules, "can_motor_control.damiao", fake_damiao) FakeRobot.last = None @@ -237,35 +240,66 @@ def test_canfd_flag_and_legacy_fd_override_can_disable_fd() -> None: def test_default_motor_specs_use_binding_motor_type_values() -> None: adapter = OpenArmRSAdapter(use_mock_bus=True) assert adapter.connect() is True - assert adapter._robot is not None - assert adapter._robot.arm.dof == 7 + robot = FakeRobot.last + assert robot is not None + assert robot.arm.dof == 7 -def test_renamed_can_motor_control_binding_is_used(monkeypatch: pytest.MonkeyPatch) -> None: +def test_can_motor_control_binding_uses_normal_import_style( + monkeypatch: pytest.MonkeyPatch, +) -> None: imported: list[str] = [] - real_import_module = importlib.import_module - - def track_can_motor_control(name: str, package: str | None = None) -> ModuleType: - if name.startswith("dm_control"): - raise ImportError(name) - imported.append(name) - return real_import_module(name, package) + real_import = builtins.__import__ + + def track_can_motor_control( + name: str, + globals: Mapping[str, object] | None = None, + locals: Mapping[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> ModuleType: + if name.startswith("can_motor_control"): + imported.append(name) + module = real_import(name, globals, locals, fromlist, level) + if not isinstance(module, ModuleType): + raise TypeError(f"expected module import for {name}") + return module - monkeypatch.setattr(importlib, "import_module", track_can_motor_control) + def fail_importlib_for_binding(name: str, package: str | None = None) -> ModuleType: + if name.startswith("can_motor_control"): + raise AssertionError("can_motor_control must use normal import syntax") + module = real_import(name, fromlist=("*",)) + if not isinstance(module, ModuleType): + raise TypeError(f"expected module import for {name}") + return module + + monkeypatch.setattr(builtins, "__import__", track_can_motor_control) + monkeypatch.setattr("importlib.import_module", fail_importlib_for_binding) adapter = OpenArmRSAdapter(use_mock_bus=True) assert adapter.connect() is True - assert imported[:2] == ["can_motor_control", "can_motor_control.damiao"] + assert imported[:2] == ["can_motor_control", "can_motor_control"] def test_missing_binding_fails_only_when_selected(monkeypatch: pytest.MonkeyPatch) -> None: - real_import_module = importlib.import_module - - def fail_can_motor_control(name: str, package: str | None = None) -> ModuleType: + real_import = builtins.__import__ + monkeypatch.delitem(sys.modules, "can_motor_control", raising=False) + monkeypatch.delitem(sys.modules, "can_motor_control.damiao", raising=False) + + def fail_can_motor_control( + name: str, + globals: Mapping[str, object] | None = None, + locals: Mapping[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> ModuleType: if name.startswith("can_motor_control"): raise ImportError(name) - return real_import_module(name, package) + module = real_import(name, globals, locals, fromlist, level) + if not isinstance(module, ModuleType): + raise TypeError(f"expected module import for {name}") + return module - monkeypatch.setattr(importlib, "import_module", fail_can_motor_control) + monkeypatch.setattr(builtins, "__import__", fail_can_motor_control) adapter = OpenArmRSAdapter(use_mock_bus=True) with pytest.raises(OpenArmRSBindingUnavailableError, match="openarm_rs.*can-motor-control"): adapter.connect() diff --git a/stubs/can_motor_control/__init__.pyi b/stubs/can_motor_control/__init__.pyi new file mode 100644 index 0000000000..308576a371 --- /dev/null +++ b/stubs/can_motor_control/__init__.pyi @@ -0,0 +1,45 @@ +from numpy import float64 +from numpy.typing import NDArray + +from . import damiao as damiao + +FloatArray = NDArray[float64] + +class MotorSpec: + def __init__(self, name: str, type: object, send_id: int, recv_id: int) -> None: ... + +class MockCanBus: + def __init__(self, name: str, fd: bool = False) -> None: ... + @staticmethod + def new_fd(name: str) -> object: ... + +class SocketCanBus: + def __init__(self, interface: str, fd: bool = False) -> None: ... + +class ArmMotor: + fault: int | None + +class Arm: + def __len__(self) -> int: ... + def __getitem__(self, name: str) -> ArmMotor: ... + def refresh(self) -> None: ... + def positions(self) -> FloatArray: ... + def velocities(self) -> FloatArray: ... + def torques(self) -> FloatArray: ... + def mit_control(self, cmds: FloatArray) -> None: ... + +class RobotBuilder: + def add_bus(self, name: str, transport: object, codec: object) -> RobotBuilder: ... + def add_arm(self, name: str, *, bus: str, motors: list[object]) -> RobotBuilder: ... + def build(self) -> Robot: ... + +class Robot: + @classmethod + def builder(cls) -> RobotBuilder: ... + @classmethod + def from_config(cls, path: str) -> Robot: ... + def __getitem__(self, name: str) -> Arm: ... + def connect(self) -> None: ... + def enable(self) -> None: ... + def disable(self) -> None: ... + def tick(self, per_bus_deadline_us: int) -> None: ... diff --git a/stubs/can_motor_control/damiao.pyi b/stubs/can_motor_control/damiao.pyi new file mode 100644 index 0000000000..e67c598608 --- /dev/null +++ b/stubs/can_motor_control/damiao.pyi @@ -0,0 +1,7 @@ +class MotorType: + DM4310: object + DM4340: object + DM8006: object + +class DamiaoCodec: + def __init__(self) -> None: ... From 7b9f1f68b4fd72b22d4d91ef795ec847863319a8 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 17:26:09 -0700 Subject: [PATCH 010/110] refactor: use normal pinocchio imports --- dimos/hardware/manipulators/damiao/base_adapter.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index 6e70db8ea5..77632b3b65 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -14,7 +14,6 @@ from __future__ import annotations -import importlib from pathlib import Path import time from typing import Protocol, cast @@ -568,18 +567,22 @@ def write_clear_errors(self) -> bool: def _load_gravity_model(self) -> None: if not self._gravity_comp or self._gravity_model_path is None: return - pinocchio = cast("_PinocchioModule", cast("object", importlib.import_module("pinocchio"))) + import pinocchio - self._pin_model = pinocchio.buildModelFromUrdf(self._gravity_model_path) + pinocchio_module = cast("_PinocchioModule", _as_object(pinocchio)) + + self._pin_model = pinocchio_module.buildModelFromUrdf(self._gravity_model_path) self._pin_data = self._pin_model.createData() def compute_gravity_torques(self, q: list[float]) -> list[float]: self._validate_length("q", q) if self._pin_model is None or self._pin_data is None: return [0.0] * self._dof - pinocchio = cast("_PinocchioModule", cast("object", importlib.import_module("pinocchio"))) + import pinocchio + + pinocchio_module = cast("_PinocchioModule", _as_object(pinocchio)) - tau = pinocchio.computeGeneralizedGravity( + tau = pinocchio_module.computeGeneralizedGravity( self._pin_model, self._pin_data, np.array(q, dtype=np.float64), From 9eda02b6e386ebd0e36ff82e64283c87aea5b919 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 17:57:34 -0700 Subject: [PATCH 011/110] feat: lazily discover manipulator adapters --- dimos/hardware/manipulators/README.md | 19 +- .../manipulators/a750/__registry__.py | 19 ++ .../manipulators/mock/__registry__.py | 19 ++ .../manipulators/openarm/__registry__.py | 19 ++ .../manipulators/openarm_rs/__registry__.py | 19 ++ .../manipulators/piper/__registry__.py | 19 ++ dimos/hardware/manipulators/registry.py | 113 +++++++--- .../hardware/manipulators/sim/__registry__.py | 19 ++ dimos/hardware/manipulators/test_registry.py | 194 ++++++++++++++++++ .../manipulators/xarm/__registry__.py | 19 ++ docs/capabilities/manipulation/a750.md | 2 +- .../manipulation/adding_a_custom_arm.md | 38 ++-- .../.openspec.yaml | 2 + .../design.md | 112 ++++++++++ .../docs.md | 28 +++ .../proposal.md | 37 ++++ .../dm-motor-manipulator-adapters/spec.md | 40 ++++ .../manipulator-adapter-discovery/spec.md | 49 +++++ .../openarm-rs-adapter-selection/spec.md | 38 ++++ .../tasks.md | 40 ++++ .../dm-motor-manipulator-adapters/spec.md | 5 +- .../manipulator-adapter-discovery/spec.md | 53 +++++ .../openarm-rs-adapter-selection/spec.md | 3 +- 23 files changed, 848 insertions(+), 58 deletions(-) create mode 100644 dimos/hardware/manipulators/a750/__registry__.py create mode 100644 dimos/hardware/manipulators/mock/__registry__.py create mode 100644 dimos/hardware/manipulators/openarm/__registry__.py create mode 100644 dimos/hardware/manipulators/openarm_rs/__registry__.py create mode 100644 dimos/hardware/manipulators/piper/__registry__.py create mode 100644 dimos/hardware/manipulators/sim/__registry__.py create mode 100644 dimos/hardware/manipulators/test_registry.py create mode 100644 dimos/hardware/manipulators/xarm/__registry__.py create mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/design.md create mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/docs.md create mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/proposal.md create mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/dm-motor-manipulator-adapters/spec.md create mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/manipulator-adapter-discovery/spec.md create mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/openarm-rs-adapter-selection/spec.md create mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/tasks.md create mode 100644 openspec/specs/manipulator-adapter-discovery/spec.md diff --git a/dimos/hardware/manipulators/README.md b/dimos/hardware/manipulators/README.md index 08c679726e..539eb7a1ec 100644 --- a/dimos/hardware/manipulators/README.md +++ b/dimos/hardware/manipulators/README.md @@ -33,12 +33,15 @@ This module provides manipulator arm drivers: Protocol-only with injectable adap ``` manipulators/ ├── spec.py # ManipulatorAdapter Protocol + shared types -├── registry.py # Adapter registry with auto-discovery +├── registry.py # Adapter registry with manifest-based discovery ├── mock/ +│ ├── __registry__.py # Adapter key → implementation path │ └── adapter.py # MockAdapter for testing ├── xarm/ +│ ├── __registry__.py │ ├── adapter.py # XArmAdapter (SDK wrapper) └── piper/ + ├── __registry__.py ├── adapter.py # PiperAdapter (SDK wrapper) ``` @@ -93,7 +96,17 @@ class MyArmAdapter: # No inheritance needed - just match the Protocol # ... implement other Protocol methods ``` -2. **Create the driver** (`arm.py`): +2. **Create the registry manifest** (`__registry__.py`): + +```python +ADAPTER_FACTORIES = { + "myarm": "dimos.hardware.manipulators.myarm.adapter:MyArmAdapter", +} +``` + +The registry imports this lightweight manifest during discovery. The adapter implementation is imported only when selected with `adapter_registry.create("myarm", ...)`. + +3. **Create the driver** (`arm.py`): ```python from dimos.core.core import rpc @@ -115,7 +128,7 @@ class MyArm(Module[MyArmConfig]): # ... setup control loops ``` -3. **Create blueprints** (`blueprints.py`) for common configurations. +4. **Create blueprints** (`blueprints.py`) for common configurations. ## ManipulatorAdapter Protocol diff --git a/dimos/hardware/manipulators/a750/__registry__.py b/dimos/hardware/manipulators/a750/__registry__.py new file mode 100644 index 0000000000..0a7ae4cab2 --- /dev/null +++ b/dimos/hardware/manipulators/a750/__registry__.py @@ -0,0 +1,19 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ADAPTER_FACTORIES = { + "a750": "dimos.hardware.manipulators.a750.adapter:A750Adapter", +} + +__all__ = ["ADAPTER_FACTORIES"] diff --git a/dimos/hardware/manipulators/mock/__registry__.py b/dimos/hardware/manipulators/mock/__registry__.py new file mode 100644 index 0000000000..eff7d0e1b5 --- /dev/null +++ b/dimos/hardware/manipulators/mock/__registry__.py @@ -0,0 +1,19 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ADAPTER_FACTORIES = { + "mock": "dimos.hardware.manipulators.mock.adapter:MockAdapter", +} + +__all__ = ["ADAPTER_FACTORIES"] diff --git a/dimos/hardware/manipulators/openarm/__registry__.py b/dimos/hardware/manipulators/openarm/__registry__.py new file mode 100644 index 0000000000..d235423637 --- /dev/null +++ b/dimos/hardware/manipulators/openarm/__registry__.py @@ -0,0 +1,19 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ADAPTER_FACTORIES = { + "openarm": "dimos.hardware.manipulators.openarm.adapter:OpenArmAdapter", +} + +__all__ = ["ADAPTER_FACTORIES"] diff --git a/dimos/hardware/manipulators/openarm_rs/__registry__.py b/dimos/hardware/manipulators/openarm_rs/__registry__.py new file mode 100644 index 0000000000..dbc9e04b19 --- /dev/null +++ b/dimos/hardware/manipulators/openarm_rs/__registry__.py @@ -0,0 +1,19 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ADAPTER_FACTORIES = { + "openarm_rs": "dimos.hardware.manipulators.openarm_rs.adapter:OpenArmRSAdapter", +} + +__all__ = ["ADAPTER_FACTORIES"] diff --git a/dimos/hardware/manipulators/piper/__registry__.py b/dimos/hardware/manipulators/piper/__registry__.py new file mode 100644 index 0000000000..17842d38a6 --- /dev/null +++ b/dimos/hardware/manipulators/piper/__registry__.py @@ -0,0 +1,19 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ADAPTER_FACTORIES = { + "piper": "dimos.hardware.manipulators.piper.adapter:PiperAdapter", +} + +__all__ = ["ADAPTER_FACTORIES"] diff --git a/dimos/hardware/manipulators/registry.py b/dimos/hardware/manipulators/registry.py index a8c87c149c..84c76fa59f 100644 --- a/dimos/hardware/manipulators/registry.py +++ b/dimos/hardware/manipulators/registry.py @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Adapter registry with auto-discovery. +"""Adapter registry with lazy auto-discovery. -Automatically discovers and registers manipulator adapters from subpackages. -Each adapter provides a `register()` function in its adapter.py module. +Automatically discovers manipulator adapters from lightweight subpackage +``__registry__.py`` manifests. Adapter implementation modules are imported +only when their adapter key is selected via :meth:`AdapterRegistry.create`. Usage: from dimos.hardware.manipulators.registry import adapter_registry @@ -31,28 +32,43 @@ from __future__ import annotations +from collections.abc import Callable, Mapping import importlib -from typing import TYPE_CHECKING, Any - -from dimos.utils.logging_config import setup_logger +from pathlib import Path +from typing import TYPE_CHECKING, cast if TYPE_CHECKING: from dimos.hardware.manipulators.spec import ManipulatorAdapter -logger = setup_logger() +AdapterFactory = Callable[..., "ManipulatorAdapter"] class AdapterRegistry: - """Registry for manipulator adapters with auto-discovery.""" + """Registry for manipulator adapters with lazy auto-discovery.""" def __init__(self) -> None: - self._adapters: dict[str, type[ManipulatorAdapter]] = {} + self._adapter_paths: dict[str, str] = {} + self._adapters: dict[str, AdapterFactory] = {} - def register(self, name: str, cls: type[ManipulatorAdapter]) -> None: - """Register an adapter class.""" + def register(self, name: str, cls: AdapterFactory) -> None: + """Register an already-imported adapter factory.""" self._adapters[name.lower()] = cls - def create(self, name: str, **kwargs: Any) -> ManipulatorAdapter: + def register_path(self, name: str, factory_path: str) -> None: + """Register a lazy adapter factory import path.""" + if ":" not in factory_path: + raise ValueError(f"Invalid adapter factory path: {factory_path!r}") + module_name, attr = factory_path.split(":", maxsplit=1) + if not module_name or not attr: + raise ValueError(f"Invalid adapter factory path: {factory_path!r}") + + key = name.lower() + existing = self._adapter_paths.get(key) + if existing is not None and existing != factory_path: + raise ValueError(f"Duplicate adapter {key!r}: {existing!r} vs {factory_path!r}") + self._adapter_paths[key] = factory_path + + def create(self, name: str, **kwargs: object) -> ManipulatorAdapter: """Create an adapter instance by name. Args: @@ -66,40 +82,69 @@ def create(self, name: str, **kwargs: Any) -> ManipulatorAdapter: KeyError: If adapter name is not found """ key = name.lower() - if key not in self._adapters: + if key not in self._adapters and key not in self._adapter_paths: raise KeyError(f"Unknown adapter: {name}. Available: {self.available()}") - return self._adapters[key](**kwargs) + return self._resolve_adapter(key)(**kwargs) def available(self) -> list[str]: """List available adapter names.""" - return sorted(self._adapters.keys()) + return sorted(set(self._adapter_paths) | set(self._adapters)) def discover(self) -> None: - """Discover and register adapters from subpackages. + """Discover and register adapter manifests from subpackages. - Scans for subdirectories containing an adapter.py module. + Scans for subdirectories containing a ``__registry__.py`` manifest. Can be called multiple times to pick up newly added adapters. """ - from pathlib import Path - - pkg_dir = Path(__file__).parent - for child in sorted(pkg_dir.iterdir()): - if not child.is_dir() or child.name.startswith(("_", ".")): - continue - if not (child / "adapter.py").exists(): - continue - try: - module = importlib.import_module( - f"dimos.hardware.manipulators.{child.name}.adapter" - ) - if hasattr(module, "register"): - module.register(self) - except ImportError as e: - logger.debug(f"Skipping adapter {child.name}: {e}") + import dimos.hardware.manipulators as pkg + + for root in pkg.__path__: + for child in sorted(Path(root).iterdir()): + if not child.is_dir() or child.name.startswith(("_", ".")): + continue + if not (child / "__registry__.py").exists(): + continue + + module_name = f"dimos.hardware.manipulators.{child.name}.__registry__" + module = importlib.import_module(module_name) + adapter_factories_obj = getattr(module, "ADAPTER_FACTORIES", None) + if not isinstance(adapter_factories_obj, Mapping): + raise TypeError(f"{module_name} must define ADAPTER_FACTORIES") + adapter_factories = cast("Mapping[object, object]", adapter_factories_obj) + for name, factory_path in adapter_factories.items(): + if not isinstance(name, str) or not isinstance(factory_path, str): + raise TypeError( + f"{module_name}.ADAPTER_FACTORIES must map strings to strings" + ) + self.register_path(name, factory_path) + + def _resolve_adapter(self, key: str) -> AdapterFactory: + if key in self._adapters: + return self._adapters[key] + factory_path = self._adapter_paths[key] + module_name, attr = factory_path.split(":", maxsplit=1) + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as exc: + if exc.name is not None and module_name.startswith(exc.name): + raise ImportError( + f"Adapter {key!r} is registered to missing module {module_name!r}" + ) from exc + raise + try: + factory = cast("AdapterFactory", getattr(module, attr)) + except AttributeError as exc: + raise ImportError( + f"Adapter {key!r} is registered to missing factory {factory_path!r}" + ) from exc + if not callable(factory): + raise TypeError(f"Adapter factory {factory_path!r} is not callable") + self._adapters[key] = factory + return factory adapter_registry = AdapterRegistry() adapter_registry.discover() -__all__ = ["AdapterRegistry", "adapter_registry"] +__all__ = ["AdapterFactory", "AdapterRegistry", "adapter_registry"] diff --git a/dimos/hardware/manipulators/sim/__registry__.py b/dimos/hardware/manipulators/sim/__registry__.py new file mode 100644 index 0000000000..b0124244d6 --- /dev/null +++ b/dimos/hardware/manipulators/sim/__registry__.py @@ -0,0 +1,19 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ADAPTER_FACTORIES = { + "sim_mujoco": "dimos.hardware.manipulators.sim.adapter:ShmMujocoAdapter", +} + +__all__ = ["ADAPTER_FACTORIES"] diff --git a/dimos/hardware/manipulators/test_registry.py b/dimos/hardware/manipulators/test_registry.py new file mode 100644 index 0000000000..35695c87d9 --- /dev/null +++ b/dimos/hardware/manipulators/test_registry.py @@ -0,0 +1,194 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import builtins +import importlib +from pathlib import Path +import sys +from types import ModuleType +from typing import Protocol, cast + +import pytest + +import dimos.hardware.manipulators as manipulators_pkg +from dimos.hardware.manipulators.mock.adapter import MockAdapter +from dimos.hardware.manipulators.registry import AdapterRegistry, adapter_registry + +EXPECTED_ADAPTER_KEYS = { + "a750", + "mock", + "openarm", + "openarm_rs", + "piper", + "sim_mujoco", + "xarm", +} + + +class _HasKwargs(Protocol): + kwargs: dict[str, object] + + +def _write_package( + root: Path, + name: str, + registry_source: str, + adapter_source: str = "", +) -> None: + package = root / name + package.mkdir() + _ = (package / "__init__.py").write_text("", encoding="utf-8") + _ = (package / "__registry__.py").write_text(registry_source, encoding="utf-8") + if adapter_source: + _ = (package / "adapter.py").write_text(adapter_source, encoding="utf-8") + + +def _clear_fake_modules() -> None: + for module_name in list(sys.modules): + if module_name.startswith("dimos.hardware.manipulators.fake"): + del sys.modules[module_name] + + +@pytest.fixture(autouse=True) +def clear_fake_modules() -> None: + _clear_fake_modules() + importlib.invalidate_caches() + + +def test_available_does_not_import_adapter_implementation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _write_package( + tmp_path, + "fake_lazy", + 'ADAPTER_FACTORIES = {"fake": "dimos.hardware.manipulators.fake_lazy.adapter:Fake"}\n', + 'raise AssertionError("adapter implementation imported during discovery")\n', + ) + monkeypatch.setattr(manipulators_pkg, "__path__", [str(tmp_path)]) + + registry = AdapterRegistry() + registry.discover() + + assert registry.available() == ["fake"] + assert "dimos.hardware.manipulators.fake_lazy.adapter" not in sys.modules + + +def test_create_imports_selected_adapter_and_passes_kwargs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _write_package( + tmp_path, + "fake_selected", + 'ADAPTER_FACTORIES = {"fake": "dimos.hardware.manipulators.fake_selected.adapter:Fake"}\n', + "\n".join( + [ + "class Fake:", + " def __init__(self, **kwargs: object) -> None:", + " self.kwargs = kwargs", + "", + ] + ), + ) + monkeypatch.setattr(manipulators_pkg, "__path__", [str(tmp_path)]) + + registry = AdapterRegistry() + registry.discover() + adapter = registry.create("fake", address="can0", dof=7) + + assert adapter.__class__.__name__ == "Fake" + assert cast("_HasKwargs", adapter).kwargs == {"address": "can0", "dof": 7} + assert "dimos.hardware.manipulators.fake_selected.adapter" in sys.modules + + +def test_direct_registration_still_creates_adapter() -> None: + registry = AdapterRegistry() + registry.register("mock", MockAdapter) + + adapter = registry.create("mock", dof=3) + + assert isinstance(adapter, MockAdapter) + assert adapter.get_dof() == 3 + + +def test_manifest_validation_rejects_bad_mapping( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _write_package(tmp_path, "fake_bad", "ADAPTER_FACTORIES = {'fake': 1}\n") + monkeypatch.setattr(manipulators_pkg, "__path__", [str(tmp_path)]) + + registry = AdapterRegistry() + with pytest.raises(TypeError, match="must map strings to strings"): + registry.discover() + + +def test_register_path_rejects_duplicate_and_invalid_paths() -> None: + registry = AdapterRegistry() + registry.register_path("fake", "pkg.mod:Factory") + + with pytest.raises(ValueError, match="Duplicate adapter"): + registry.register_path("fake", "pkg.other:Factory") + with pytest.raises(ValueError, match="Invalid adapter factory path"): + registry.register_path("bad", "pkg.mod.Factory") + with pytest.raises(ValueError, match="Invalid adapter factory path"): + registry.register_path("bad", "pkg.mod:") + + +def test_create_reports_missing_selected_module_and_attribute() -> None: + registry = AdapterRegistry() + registry.register_path( + "missing_module", "dimos.hardware.manipulators.fake_missing.adapter:Fake" + ) + registry.register_path("missing_attr", "dimos.hardware.manipulators.registry:MissingFactory") + + with pytest.raises(ImportError, match="missing_module.*missing module"): + _ = registry.create("missing_module") + with pytest.raises(ImportError, match="missing_attr.*missing factory"): + _ = registry.create("missing_attr") + with pytest.raises(KeyError, match="Unknown adapter: unknown"): + _ = registry.create("unknown") + + +def test_builtin_registry_preserves_adapter_keys() -> None: + assert EXPECTED_ADAPTER_KEYS.issubset(set(adapter_registry.available())) + + +def test_discovery_does_not_import_can_motor_control( + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_import = builtins.__import__ + + def fail_can_motor_control( + name: str, + globals: dict[str, object] | None = None, + locals: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> ModuleType: + if name.startswith("can_motor_control"): + raise AssertionError("can_motor_control imported during discovery") + module_obj = cast("object", real_import(name, globals, locals, fromlist, level)) + if not isinstance(module_obj, ModuleType): + raise TypeError(f"expected module import for {name}") + return module_obj + + monkeypatch.delitem(sys.modules, "can_motor_control", raising=False) + monkeypatch.delitem(sys.modules, "can_motor_control.damiao", raising=False) + monkeypatch.setattr(builtins, "__import__", fail_can_motor_control) + + registry = AdapterRegistry() + registry.discover() + + assert "openarm_rs" in registry.available() diff --git a/dimos/hardware/manipulators/xarm/__registry__.py b/dimos/hardware/manipulators/xarm/__registry__.py new file mode 100644 index 0000000000..5545e1ae01 --- /dev/null +++ b/dimos/hardware/manipulators/xarm/__registry__.py @@ -0,0 +1,19 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ADAPTER_FACTORIES = { + "xarm": "dimos.hardware.manipulators.xarm.adapter:XArmAdapter", +} + +__all__ = ["ADAPTER_FACTORIES"] diff --git a/docs/capabilities/manipulation/a750.md b/docs/capabilities/manipulation/a750.md index aa435bdd85..616fcd7cd5 100644 --- a/docs/capabilities/manipulation/a750.md +++ b/docs/capabilities/manipulation/a750.md @@ -79,7 +79,7 @@ The adapter reads and commands gripper position in meters using the `a750_contro ## Hardware Adapter -The adapter is registered as `a750` in [`dimos/hardware/manipulators/a750/adapter.py`](/dimos/hardware/manipulators/a750/adapter.py#L40). +The adapter is registered as `a750` through the manipulator adapter manifest in [`dimos/hardware/manipulators/a750/__registry__.py`](/dimos/hardware/manipulators/a750/__registry__.py#L16). It supports: diff --git a/docs/capabilities/manipulation/adding_a_custom_arm.md b/docs/capabilities/manipulation/adding_a_custom_arm.md index 0fd27b4e46..70e73cff12 100644 --- a/docs/capabilities/manipulation/adding_a_custom_arm.md +++ b/docs/capabilities/manipulation/adding_a_custom_arm.md @@ -45,12 +45,13 @@ Create a new directory for your arm under `dimos/hardware/manipulators/`: ``` dimos/hardware/manipulators/ ├── spec.py # ManipulatorAdapter Protocol (don't modify) -├── registry.py # Auto-discovery registry (don't modify) +├── registry.py # Manifest-based adapter registry (don't modify) ├── mock/ ├── xarm/ ├── piper/ └── yourarm/ # ← New directory ├── __init__.py + ├── __registry__.py └── adapter.py ``` @@ -76,14 +77,10 @@ DimOS Units: angles=radians, distance=meters, velocity=rad/s from __future__ import annotations import math -from typing import TYPE_CHECKING # Import your vendor SDK from yourarm_sdk import YourArmSDK -if TYPE_CHECKING: - from dimos.hardware.manipulators.registry import AdapterRegistry - from dimos.hardware.manipulators.spec import ( ControlMode, JointLimits, @@ -335,13 +332,6 @@ class YourArmAdapter: """Read F/T sensor data [fx, fy, fz, tx, ty, tz]. None if no sensor.""" return None - -# ── Registry hook (required for auto-discovery) ─────────────────── -def register(registry: AdapterRegistry) -> None: - """Register this adapter with the registry.""" - registry.register("yourarm", YourArmAdapter) - - __all__ = ["YourArmAdapter"] ``` @@ -381,15 +371,27 @@ from dimos.hardware.manipulators.yourarm.adapter import YourArmAdapter __all__ = ["YourArmAdapter"] ``` +### `__registry__.py` + +Create a lightweight manifest next to `adapter.py`: + +```python skip +ADAPTER_FACTORIES = { + "yourarm": "dimos.hardware.manipulators.yourarm.adapter:YourArmAdapter", +} + +__all__ = ["ADAPTER_FACTORIES"] +``` + ### How auto-discovery works -The `AdapterRegistry` in `dimos/hardware/manipulators/registry.py` automatically discovers your adapter at import time: +The `AdapterRegistry` in `dimos/hardware/manipulators/registry.py` automatically discovers your adapter metadata at import time: 1. It iterates over all subpackages under `dimos/hardware/manipulators/` -2. For each subpackage, it tries to import `.adapter` -3. If that module has a `register()` function, it calls it +2. For each subpackage, it imports `.__registry__` +3. It reads `ADAPTER_FACTORIES` entries such as `"yourarm": "dimos.hardware.manipulators.yourarm.adapter:YourArmAdapter"` -This means **no manual registration is needed** — just having the `register()` function in your `adapter.py` is sufficient. +This means **no central registry edit is needed**. The adapter implementation module is imported lazily only when the adapter is selected through `adapter_registry.create("yourarm", ...)`, so unrelated adapter listing does not import your vendor SDK. You can verify discovery works: @@ -688,7 +690,8 @@ adapter.disconnect() Files to create: - [ ] `dimos/hardware/manipulators/yourarm/__init__.py` -- [ ] `dimos/hardware/manipulators/yourarm/adapter.py` (implements Protocol + `register()`) +- [ ] `dimos/hardware/manipulators/yourarm/__registry__.py` (maps adapter key to implementation path) +- [ ] `dimos/hardware/manipulators/yourarm/adapter.py` (implements Protocol) - [ ] `dimos/robot/yourarm/__init__.py` - [ ] `dimos/robot/yourarm/blueprints.py` (coordinator + planning blueprints) @@ -699,5 +702,6 @@ Files to modify: Verification: - [ ] `adapter_registry.available()` includes `"yourarm"` +- [ ] `adapter_registry.create("yourarm", address="192.168.1.100", dof=6)` returns your adapter - [ ] `pytest dimos/robot/test_all_blueprints_generation.py` passes (regenerates `all_blueprints.py`) - [ ] `dimos run coordinator-yourarm` starts successfully diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/.openspec.yaml b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/.openspec.yaml new file mode 100644 index 0000000000..50d33507f6 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-07 diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/design.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/design.md new file mode 100644 index 0000000000..267d171ab7 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/design.md @@ -0,0 +1,112 @@ +## Context + +`dimos.hardware.manipulators.registry.AdapterRegistry` currently discovers adapters by scanning `dimos/hardware/manipulators/` and importing each `/adapter.py` module during registry import. That makes a simple `from dimos.hardware.manipulators.registry import adapter_registry` execute implementation modules for `xarm`, `piper`, `openarm_rs`, `sim_mujoco`, and other adapters before a user has selected an adapter. + +This conflicts with existing requirements for optional hardware bindings. `openarm_rs` must remain discoverable when `can_motor_control` is not installed, and missing binding errors must be scoped to selecting or connecting `openarm_rs`. The current Damiao/OpenArm RS base compensates by keeping binding imports lazy and typing them through local Protocols. That preserves behavior but makes implementation code harder to read and maintain. + +The control-task registry already solves the same class of problem. `dimos/control/tasks/registry.py` imports lightweight `__registry__.py` manifests containing string import paths, and imports the real task implementation only when the selected task is created. + +## Goals / Non-Goals + +**Goals:** + +- Make manipulator adapter discovery metadata-only: listing adapters must not import unselected adapter implementation modules. +- Preserve existing adapter keys and the `adapter_registry.available()` / `adapter_registry.create(name, **kwargs)` API. +- Keep optional dependency failures selected-adapter scoped, especially for `openarm_rs` and future Damiao-backed adapters. +- Establish a contributor pattern for adding manipulator adapters through lightweight manifests. +- Enable follow-up simplification of Damiao/OpenArm RS implementation imports where normal selected-path imports are clearer. + +**Non-Goals:** + +- Do not change stream contracts, coordinator task behavior, blueprint names, or MCP/skill exposure. +- Do not migrate drive-train or whole-body registries in this change, though they may benefit from the same pattern later. +- Do not introduce setuptools entry points or third-party plugin packaging for in-tree adapters now. +- Do not make `openarm_rs` a generic Damiao adapter or change its hardware safety semantics. + +## DimOS Architecture + +The affected runtime path is the manipulator adapter selection path used by `ControlCoordinator._create_adapter()`: + +```text +HardwareComponent(adapter_type=...) + -> ControlCoordinator._create_adapter() + -> adapter_registry.create(adapter_type, dof=..., address=..., hardware_id=..., **kwargs) + -> selected manipulator adapter instance +``` + +No `In[T]`/`Out[T]` stream names or transport choices change. The `ManipulatorAdapter` Protocol remains the adapter surface. No new DimOS `Spec` Protocol, RPC contract, skill, or MCP tool is required. + +The registry should mirror the control-task lazy registry structure: + +```text +dimos/hardware/manipulators//__registry__.py + ADAPTER_FACTORIES = { + "adapter_key": "dimos.hardware.manipulators..adapter:AdapterClass" + } + +dimos/hardware/manipulators/registry.py + discover() imports only __registry__.py manifests + available() returns manifest keys + create(name, **kwargs) resolves the selected import path and instantiates it +``` + +The registry should cache resolved factories/classes after the first selected import, like `ControlTaskRegistry._factories`, so repeated `create()` calls for the same adapter do not repeatedly import the module. + +## Decisions + +1. **Use per-adapter `__registry__.py` manifests for built-in adapters.** + - Rationale: This is the closest in-repo precedent and works for source-tree adapters without packaging entry points. + - Alternative considered: Python package entry points. They are a good long-term extension point for third-party adapter packages, but they add packaging complexity and are unnecessary for in-tree adapters. + +2. **Store adapter factories as `"module:attr"` strings.** + - Rationale: Strings are safe to read during discovery and keep implementation modules unimported until selected. + - The registry should validate the colon format, duplicate adapter keys, and non-string mappings when reading manifests. + +3. **Preserve `register(name, cls)` for direct tests/manual registration, but make discovery use `register_path()`.** + - Rationale: Existing adapter unit tests call `register(registry)` directly and external code may construct an `AdapterRegistry` for tests. Keeping direct registration avoids unnecessary churn. + - Discovery should not call old adapter-level `register()` functions because doing so requires importing implementation modules. + +4. **Fail clearly on selected adapter load errors.** + - Unknown adapter names should list `available()` keys. + - Missing implementation module or attribute should identify the selected adapter key and import path. + - Optional dependency errors should remain adapter-specific. `openarm_rs` should keep its current clear missing-binding error when the selected path reaches its binding import. + +5. **Update all built-in manipulator adapters in one migration.** + - Rationale: Partial migration could make `available()` omit existing adapters or keep eager imports for some adapters. Each existing adapter package should get a manifest preserving its current key. + +## Safety / Simulation / Replay + +This change should not alter hardware commands or simulation behavior. It only changes when adapter implementation modules are imported. + +Safety-relevant expectations: + +- Listing adapters must be safe in environments without hardware SDKs or platform-gated extras. +- Selecting an adapter that needs a missing SDK must fail before unsafe hardware commands are issued. +- `openarm_rs` must preserve its staged validation path: binding availability, mock/virtual CAN validation, then real hardware gravity/trajectory validation. +- `sim_mujoco` should remain selectable by key without causing simulation imports during unrelated adapter listing. + +Manual QA should use the library surface: import the registry, list adapters, create a mock adapter, and exercise selected-adapter missing-binding behavior for an optional adapter in a controlled test environment. + +## Risks / Trade-offs + +- **Manifest drift:** A manifest path can point at a missing module or attribute. Mitigate with focused registry tests that resolve every built-in adapter factory in an environment with project extras available, plus clear selected-path errors. +- **Duplicate keys:** Multiple manifests can claim the same adapter key. Mitigate by rejecting conflicting registrations during discovery. +- **Behavioral timing shift:** Some import-time errors move from registry import to `create()` or adapter connect. This is intentional for optional dependencies, but selected-path errors must remain actionable. +- **Docs mismatch:** Existing docs describe `adapter.py` auto-discovery. Update adapter authoring docs so new adapters add a manifest. +- **Scope creep:** Drive-train and whole-body registries use similar eager patterns, but migrating them is out of scope for this change. + +## Migration / Rollout + +1. Add lazy path registration support to `AdapterRegistry` while preserving direct class registration for tests. +2. Change manipulator discovery to import `.__registry__` manifests instead of `.adapter` modules. +3. Add `__registry__.py` manifests for each existing built-in manipulator adapter key. +4. Update tests to assert that registry import/listing does not import adapter implementation modules and that `create()` imports only the selected adapter. +5. Update OpenArm RS and Damiao binding tests to preserve selected-adapter-scoped missing-binding behavior. +6. Update manipulator adapter authoring documentation. + +No generated blueprint registry update is expected. Rollback is straightforward: restore eager adapter module discovery and remove manifests, but that would also restore registry-time optional import pressure. + +## Open Questions + +- Should external third-party manipulator adapters eventually use Python entry points in addition to in-tree manifests? +- Should drive-train and whole-body registries be migrated to the same manifest pattern in a follow-up change? diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/docs.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/docs.md new file mode 100644 index 0000000000..298e84c0fc --- /dev/null +++ b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/docs.md @@ -0,0 +1,28 @@ +## User-Facing Docs + +- Update `dimos/hardware/manipulators/README.md` to describe manifest-based adapter discovery instead of `adapter.py` auto-discovery. +- Update `docs/capabilities/manipulation/adding_a_custom_arm.md` so custom adapter instructions include: + - creating `/__registry__.py`, + - mapping adapter keys to `"module:ClassOrFactory"` import paths, + - keeping optional hardware SDK failures scoped to selected adapter creation/connect, + - verifying `adapter_registry.available()` and `adapter_registry.create()`. +- Keep `docs/capabilities/manipulation/openarm_integration.md` wording that `openarm_rs` remains opt-in and missing `can_motor_control` fails only when selected. Update only if implementation changes the exact failure timing or wording. + +## Contributor Docs + +- No broad `docs/development/` update is required unless implementation introduces a new registry-generation or validation command. +- If registry tests add a dedicated contributor workflow for adapter manifests, document it near the manipulator docs rather than in general development docs. + +## Coding-Agent Docs + +- No `AGENTS.md` update is required for this change. +- Consider updating `docs/coding-agents/` only if there is an existing manipulator adapter authoring guide for coding agents; otherwise the user-facing custom-arm guide is sufficient. + +## Doc Validation + +- Run markdown/doc validation used by this repo for changed docs, such as `doclinks` if available. +- For changed Python snippets in `docs/capabilities/manipulation/adding_a_custom_arm.md`, run the repository's markdown Python snippet validator if configured, or manually inspect fenced snippets for import path accuracy. + +## No Docs Needed + +Documentation changes are needed because existing custom-arm docs currently teach `adapter.py` auto-discovery, which would become stale after manifest-based discovery. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/proposal.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/proposal.md new file mode 100644 index 0000000000..f52b197bfe --- /dev/null +++ b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/proposal.md @@ -0,0 +1,37 @@ +## Why + +The manipulator adapter registry currently discovers adapters by importing every `dimos.hardware.manipulators..adapter` module at registry import time. That makes discovery depend on every adapter module being safe to import in every environment, even when a user only wants to list adapters or instantiate an unrelated adapter. + +This is especially painful for optional hardware bindings such as the Rust-backed Damiao/OpenArm RS path: adapter implementation files must avoid normal imports and carry extra lazy-import/protocol/cast boilerplate so registry discovery does not fail or trigger heavy SDK side effects. DimOS already has a cleaner precedent in the control task registry: lightweight manifest modules advertise available names, while implementation imports happen only for the selected type. + +## What Changes + +- Modify manipulator adapter discovery to import lightweight per-adapter registry manifests instead of importing every adapter implementation module. +- Register adapter names to lazy factory import paths and resolve exactly one adapter implementation when `adapter_registry.create(name, **kwargs)` is called. +- Preserve the existing public `adapter_registry.available()` and `adapter_registry.create()` library surface. +- Preserve selected-adapter-scoped optional dependency failures: missing hardware bindings should not break discovery or unrelated adapters, and should fail clearly when the affected adapter is selected. +- Add compatibility support for existing direct `register(registry)` adapter modules during migration only if it does not reintroduce eager implementation imports. +- No **BREAKING** public API or CLI behavior is intended. + +## Affected DimOS Surfaces + +- Modules/streams: No stream contracts change. Control coordinator manipulator adapter creation continues through `HardwareComponent.adapter_type` and `adapter_registry.create()`. +- Blueprints/CLI: Existing blueprints and CLI runs that select manipulator adapters should keep the same adapter keys. +- Skills/MCP: No direct MCP or skill surface change. +- Hardware/simulation/replay: Manipulator hardware adapters, including `openarm`, `openarm_rs`, `xarm`, `piper`, `a750`, `mock`, and `sim_mujoco`, remain selectable by the same names. Optional SDK failures remain scoped to selected adapters. +- Docs/generated registries: Update manipulator adapter authoring docs to describe manifest-based lazy discovery. No generated blueprint registry changes are expected. + +## Capabilities + +### New Capabilities +- `manipulator-adapter-discovery`: Behavior for discovering, listing, and instantiating manipulator adapters without importing unselected adapter implementations. + +### Modified Capabilities +- `openarm-rs-adapter-selection`: Preserve and clarify the requirement that missing `can_motor_control` fails only when `openarm_rs` is selected, not during registry discovery. +- `dm-motor-manipulator-adapters`: Preserve and clarify selected-adapter-scoped binding availability for Damiao/DMMotor-backed manipulator adapters. + +## Impact + +Users should see the same adapter names and selection behavior, but adapter listing/import becomes more reliable in partial installations. Developers can write adapter implementation modules with normal imports appropriate to selected-adapter runtime paths instead of keeping all implementation modules discovery-safe. + +Compatibility risk is concentrated in registry migration: existing adapter keys must remain stable, duplicate manifests must be rejected clearly, and missing manifest or malformed import paths must produce actionable errors. Test coverage should prove that `available()` does not import adapter implementations, `create()` imports only the requested implementation, and missing optional bindings do not break unrelated adapters. Manual QA should exercise the library surface by listing adapters and creating at least mock plus one lazy-registered adapter path. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/dm-motor-manipulator-adapters/spec.md new file mode 100644 index 0000000000..7b03b5b164 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/dm-motor-manipulator-adapters/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Binding-backed OpenArm adapter scope + +DimOS SHALL narrow the current binding-backed DMMotor/OpenArm behavior into an explicit OpenArm RS adapter path rather than presenting it as a generic DMMotor arm adapter. + +#### Scenario: Selecting the renamed binding-backed adapter +- **GIVEN** a hardware configuration needs the Rust-backed OpenArm binding path +- **WHEN** the configuration selects an adapter type +- **THEN** it SHALL use `openarm_rs` for the binding-backed OpenArm adapter +- **AND** it SHALL NOT rely on `dm_motor_arm` as the documented OpenArm binding-backed adapter key. + +#### Scenario: Avoiding generic Damiao defaults +- **GIVEN** a non-OpenArm Damiao arm needs support in the future +- **WHEN** a developer evaluates the OpenArm RS adapter +- **THEN** DimOS SHALL make clear that OpenArm RS metadata and defaults are OpenArm-specific +- **AND** DimOS MUST require a separate explicit adapter or change before treating those defaults as generic Damiao behavior. + +### Requirement: Binding-backed adapter safety behavior + +DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. + +#### Scenario: Coherent state reads +- **GIVEN** the OpenArm RS adapter is connected through the binding-backed path +- **WHEN** DimOS reads positions, velocities, and efforts during one coordinator cycle +- **THEN** the adapter SHALL provide a coherent state snapshot rather than independently loading the CAN bus for each read +- **AND** stale or malformed state SHALL be surfaced before unsafe commands are sent. + +#### Scenario: Gravity-compensation-only command +- **GIVEN** a user invokes a gravity-compensation-only validation path through the binding-backed adapter +- **WHEN** the adapter sends MIT commands for gravity compensation +- **THEN** the command SHALL use zero position stiffness so the arm remains manually movable +- **AND** the command SHALL use current measured state and configured OpenArm gravity metadata. + +#### Scenario: Missing binding remains selected-adapter scoped +- **GIVEN** the optional binding is not installed +- **WHEN** DimOS discovers or lists manipulator adapters without selecting OpenArm RS +- **THEN** discovery SHALL continue for adapters that do not require the binding +- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path +- **AND** registry listing MUST NOT import the OpenArm RS implementation only to discover its adapter key. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/manipulator-adapter-discovery/spec.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/manipulator-adapter-discovery/spec.md new file mode 100644 index 0000000000..18277bae5d --- /dev/null +++ b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/manipulator-adapter-discovery/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Metadata-only manipulator adapter listing + +DimOS SHALL list registered manipulator adapter keys without importing unselected adapter implementation modules. + +#### Scenario: Listing adapters in a partial installation +- **GIVEN** a DimOS environment where one or more optional manipulator hardware SDKs are not installed +- **WHEN** a user imports the manipulator adapter registry and asks for available adapter keys +- **THEN** DimOS SHALL return the known adapter keys whose lightweight registry metadata is available +- **AND** DimOS MUST NOT import unselected adapter implementation modules only to produce that list. + +#### Scenario: Unrelated adapter remains discoverable +- **GIVEN** an optional binding required by one manipulator adapter is not importable +- **WHEN** a user lists manipulator adapters without selecting that adapter +- **THEN** DimOS SHALL continue discovering unrelated manipulator adapters +- **AND** missing optional dependency errors SHALL NOT prevent unrelated adapter keys from appearing. + +### Requirement: Selected manipulator adapter loading + +DimOS SHALL import and instantiate only the manipulator adapter selected by `adapter_registry.create(name, **kwargs)`. + +#### Scenario: Creating a selected adapter +- **GIVEN** a registered manipulator adapter key and constructor arguments for that adapter +- **WHEN** a user calls `adapter_registry.create()` with that key +- **THEN** DimOS SHALL resolve the selected adapter implementation +- **AND** DimOS SHALL instantiate the selected adapter with the provided arguments. + +#### Scenario: Unknown adapter name +- **GIVEN** a manipulator adapter key is not registered +- **WHEN** a user calls `adapter_registry.create()` with that key +- **THEN** DimOS SHALL fail with an error that identifies the unknown adapter +- **AND** the error SHALL include the currently available adapter keys. + +#### Scenario: Broken selected adapter registration +- **GIVEN** a manipulator adapter key is registered to an implementation path that cannot be resolved +- **WHEN** a user selects that adapter key +- **THEN** DimOS SHALL fail with an actionable selected-adapter error +- **AND** the error SHALL identify the selected adapter key or its configured implementation path. + +### Requirement: Stable manipulator adapter keys + +DimOS SHALL preserve existing built-in manipulator adapter keys across the lazy discovery migration. + +#### Scenario: Existing adapter keys remain available +- **GIVEN** a user has a hardware configuration or blueprint that selects an existing built-in manipulator adapter key +- **WHEN** DimOS discovers manipulator adapters after this change +- **THEN** that key SHALL remain available if its lightweight registry metadata is present +- **AND** users SHALL NOT need to rename existing manipulator adapter selections for this registry migration. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/openarm-rs-adapter-selection/spec.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/openarm-rs-adapter-selection/spec.md new file mode 100644 index 0000000000..932eb0c84a --- /dev/null +++ b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/openarm-rs-adapter-selection/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: Explicit OpenArm RS adapter selection + +DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. + +#### Scenario: Selecting the Rust-backed OpenArm path +- **GIVEN** a hardware configuration selects the binding-backed OpenArm adapter +- **WHEN** DimOS initializes manipulator hardware through the adapter registry +- **THEN** the configuration SHALL select the adapter using the `openarm_rs` key +- **AND** the selected adapter SHALL represent OpenArm hardware rather than a generic Damiao arm. + +#### Scenario: Binding unavailable for OpenArm RS +- **GIVEN** the `can_motor_control` binding is not importable in the runtime environment +- **WHEN** a user selects the `openarm_rs` adapter +- **THEN** DimOS SHALL fail with a clear selected-adapter missing-binding error +- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding +- **AND** listing manipulator adapters MUST NOT import the OpenArm RS implementation only to discover its adapter key. + +### Requirement: OpenArm RS blueprint naming + +DimOS SHALL expose binding-backed OpenArm runnable blueprints with names that identify the OpenArm RS path. + +#### Scenario: Listing binding-backed OpenArm blueprints +- **GIVEN** binding-backed OpenArm blueprints are exported +- **WHEN** a user lists or runs OpenArm blueprints through the DimOS CLI +- **THEN** runnable names SHALL use `openarm-rs` wording instead of `dm-motor-openarm` wording +- **AND** documentation SHALL describe those blueprints as opt-in alternatives to the stable `openarm` path. + +### Requirement: OpenArm RS safety staging + +DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. + +#### Scenario: Validating before real hardware operation +- **GIVEN** a user wants to run OpenArm hardware through the `openarm_rs` path +- **WHEN** they follow DimOS bring-up guidance +- **THEN** the guidance SHALL start with binding availability and mock or virtual CAN validation +- **AND** it SHALL treat real hardware gravity compensation and trajectory execution as later staged QA steps. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/tasks.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/tasks.md new file mode 100644 index 0000000000..04968f8e0a --- /dev/null +++ b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/tasks.md @@ -0,0 +1,40 @@ +## 1. Registry Implementation + +- [x] 1.1 Update `dimos/hardware/manipulators/registry.py` to store lazy adapter factory import paths alongside directly registered adapter classes. +- [x] 1.2 Add a `register_path(name, factory_path)`-style API with `module:attribute` validation, duplicate-key detection, and actionable errors for malformed paths. +- [x] 1.3 Change manipulator discovery to import `.__registry__` manifests and read an `ADAPTER_FACTORIES` string mapping instead of importing `.adapter` implementation modules. +- [x] 1.4 Resolve and cache the selected adapter implementation only inside `adapter_registry.create(name, **kwargs)`. +- [x] 1.5 Preserve existing `register(name, cls)` behavior for direct programmatic registration and existing adapter-level unit tests. +- [x] 1.6 Add lightweight `__registry__.py` manifests for built-in adapters: `a750`, `mock`, `openarm`, `openarm_rs`, `piper`, `sim`, and `xarm`. +- [x] 1.7 Keep current adapter keys unchanged: `a750`, `mock`, `openarm`, `openarm_rs`, `piper`, `sim_mujoco`, and `xarm`. + +## 2. Adapter Cleanup Boundaries + +- [x] 2.1 Review `dimos/hardware/manipulators/damiao/base_adapter.py` after lazy registry discovery is in place and simplify only import scaffolding that no longer protects registry listing. +- [x] 2.2 Preserve OpenArm RS selected-adapter missing-binding error behavior and install guidance for absent `can_motor_control`. +- [x] 2.3 Avoid changing hardware command semantics, gravity compensation behavior, state caching, or adapter constructor contracts while refactoring discovery. + +## 3. Tests + +- [x] 3.1 Add focused manipulator registry tests proving `adapter_registry.available()` does not import adapter implementation modules. +- [x] 3.2 Add tests proving `adapter_registry.create()` imports only the selected adapter implementation and passes constructor kwargs through. +- [x] 3.3 Add tests for malformed manifest entries, duplicate adapter keys, unknown adapter names, and missing selected implementation paths. +- [x] 3.4 Add or update tests proving registry discovery/listing remains healthy when `can_motor_control` is not importable. +- [x] 3.5 Update OpenArm RS/Damiao tests to preserve normal selected-path import style and clear missing-binding failure behavior. +- [x] 3.6 Add a regression test that expected built-in manipulator adapter keys remain available after manifest migration. + +## 4. Documentation + +- [x] 4.1 Update `docs/capabilities/manipulation/adding_a_custom_arm.md` to teach `__registry__.py` manifest-based discovery and lazy selected adapter imports. +- [x] 4.2 Update `dimos/hardware/manipulators/README.md` if its adapter structure or adding-a-new-arm section still describes eager `adapter.py` discovery. +- [x] 4.3 Review `docs/capabilities/manipulation/openarm_integration.md` and keep or adjust wording that `openarm_rs` remains opt-in and missing binding failures are selected-adapter scoped. + +## 5. Verification + +- [x] 5.1 Run `openspec validate lazy-manipulator-adapter-registry`. +- [x] 5.2 Run focused registry tests for `dimos/hardware/manipulators/registry.py` and built-in manipulator manifest discovery. +- [x] 5.3 Run focused OpenArm RS/Damiao adapter tests covering `can_motor_control` missing-binding behavior. +- [x] 5.4 Run focused tests for adapters whose manifests changed where construction is safe without hardware. +- [x] 5.5 Run lints/types for changed Python files, including `uv run mypy` or narrower type checks if the repository supports them for this area. +- [x] 5.6 Run documentation validation for changed docs, or manually inspect docs if no doc validation command is available. +- [x] 5.7 Manually QA through the library surface: import `adapter_registry`, call `available()`, create a `mock` adapter, and verify selecting `openarm_rs` without `can_motor_control` fails with a clear selected-adapter error while unrelated adapters remain listed. diff --git a/openspec/specs/dm-motor-manipulator-adapters/spec.md b/openspec/specs/dm-motor-manipulator-adapters/spec.md index e169d2737a..f3f6cbf048 100644 --- a/openspec/specs/dm-motor-manipulator-adapters/spec.md +++ b/openspec/specs/dm-motor-manipulator-adapters/spec.md @@ -38,6 +38,7 @@ DimOS SHALL preserve the safety expectations already required for the binding-ba #### Scenario: Missing binding remains selected-adapter scoped - **GIVEN** the optional binding is not installed -- **WHEN** DimOS discovers manipulator adapters without selecting OpenArm RS +- **WHEN** DimOS discovers or lists manipulator adapters without selecting OpenArm RS - **THEN** discovery SHALL continue for adapters that do not require the binding -- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path. +- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path +- **AND** registry listing MUST NOT import the OpenArm RS implementation only to discover its adapter key. diff --git a/openspec/specs/manipulator-adapter-discovery/spec.md b/openspec/specs/manipulator-adapter-discovery/spec.md new file mode 100644 index 0000000000..a6d0b6e231 --- /dev/null +++ b/openspec/specs/manipulator-adapter-discovery/spec.md @@ -0,0 +1,53 @@ +## Purpose + +Define lazy manipulator adapter discovery behavior so adapter keys remain listable in partial installations without importing unselected hardware SDKs. + +## Requirements + +### Requirement: Metadata-only manipulator adapter listing + +DimOS SHALL list registered manipulator adapter keys without importing unselected adapter implementation modules. + +#### Scenario: Listing adapters in a partial installation +- **GIVEN** a DimOS environment where one or more optional manipulator hardware SDKs are not installed +- **WHEN** a user imports the manipulator adapter registry and asks for available adapter keys +- **THEN** DimOS SHALL return the known adapter keys whose lightweight registry metadata is available +- **AND** DimOS MUST NOT import unselected adapter implementation modules only to produce that list. + +#### Scenario: Unrelated adapter remains discoverable +- **GIVEN** an optional binding required by one manipulator adapter is not importable +- **WHEN** a user lists manipulator adapters without selecting that adapter +- **THEN** DimOS SHALL continue discovering unrelated manipulator adapters +- **AND** missing optional dependency errors SHALL NOT prevent unrelated adapter keys from appearing. + +### Requirement: Selected manipulator adapter loading + +DimOS SHALL import and instantiate only the manipulator adapter selected by `adapter_registry.create(name, **kwargs)`. + +#### Scenario: Creating a selected adapter +- **GIVEN** a registered manipulator adapter key and constructor arguments for that adapter +- **WHEN** a user calls `adapter_registry.create()` with that key +- **THEN** DimOS SHALL resolve the selected adapter implementation +- **AND** DimOS SHALL instantiate the selected adapter with the provided arguments. + +#### Scenario: Unknown adapter name +- **GIVEN** a manipulator adapter key is not registered +- **WHEN** a user calls `adapter_registry.create()` with that key +- **THEN** DimOS SHALL fail with an error that identifies the unknown adapter +- **AND** the error SHALL include the currently available adapter keys. + +#### Scenario: Broken selected adapter registration +- **GIVEN** a manipulator adapter key is registered to an implementation path that cannot be resolved +- **WHEN** a user selects that adapter key +- **THEN** DimOS SHALL fail with an actionable selected-adapter error +- **AND** the error SHALL identify the selected adapter key or its configured implementation path. + +### Requirement: Stable manipulator adapter keys + +DimOS SHALL preserve existing built-in manipulator adapter keys across the lazy discovery migration. + +#### Scenario: Existing adapter keys remain available +- **GIVEN** a user has a hardware configuration or blueprint that selects an existing built-in manipulator adapter key +- **WHEN** DimOS discovers manipulator adapters after this change +- **THEN** that key SHALL remain available if its lightweight registry metadata is present +- **AND** users SHALL NOT need to rename existing manipulator adapter selections for this registry migration. diff --git a/openspec/specs/openarm-rs-adapter-selection/spec.md b/openspec/specs/openarm-rs-adapter-selection/spec.md index b1f678fadf..5b227ed7a6 100644 --- a/openspec/specs/openarm-rs-adapter-selection/spec.md +++ b/openspec/specs/openarm-rs-adapter-selection/spec.md @@ -18,7 +18,8 @@ DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface na - **GIVEN** the `can_motor_control` binding is not importable in the runtime environment - **WHEN** a user selects the `openarm_rs` adapter - **THEN** DimOS SHALL fail with a clear selected-adapter missing-binding error -- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding. +- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding +- **AND** listing manipulator adapters MUST NOT import the OpenArm RS implementation only to discover its adapter key. ### Requirement: OpenArm RS blueprint naming From 3a2dda922b682d9ce9029dcf0d4035b792c3612d Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 18:15:47 -0700 Subject: [PATCH 012/110] refactor: simplify damiao binding adapter --- .../manipulators/damiao/base_adapter.py | 188 +++++------------- .../manipulators/openarm_rs/test_adapter.py | 64 ++---- 2 files changed, 67 insertions(+), 185 deletions(-) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index 77632b3b65..4539bd2184 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -16,7 +16,7 @@ from pathlib import Path import time -from typing import Protocol, cast +from typing import Any, cast import numpy as np from numpy.typing import NDArray @@ -29,136 +29,56 @@ FloatArray = NDArray[np.float64] +_can_motor_control: Any | None +_damiao: Any | None -class _SupportsInt(Protocol): - def __int__(self) -> int: ... - - -class _PinocchioModel(Protocol): - def createData(self) -> object: ... - - -class _PinocchioModule(Protocol): - def buildModelFromUrdf(self, filename: str) -> _PinocchioModel: ... - - def computeGeneralizedGravity( - self, model: _PinocchioModel, data: object, q: FloatArray - ) -> FloatArray: ... - - -class _Motor(Protocol): - fault: int | None - - -class _Arm(Protocol): - def __len__(self) -> int: ... - - def __getitem__(self, name: str) -> _Motor: ... - - def refresh(self) -> None: ... - - def positions(self) -> FloatArray: ... - - def velocities(self) -> FloatArray: ... - - def torques(self) -> FloatArray: ... - - def mit_control(self, cmds: FloatArray) -> None: ... - - -class _Robot(Protocol): - def __getitem__(self, name: str) -> _Arm: ... - - def connect(self) -> None: ... - - def enable(self) -> None: ... - - def disable(self) -> None: ... - - def tick(self, per_bus_deadline_us: int) -> None: ... - - -class _RobotBuilder(Protocol): - def add_bus(self, name: str, transport: object, codec: object) -> _RobotBuilder: ... - - def add_arm(self, name: str, *, bus: str, motors: list[object]) -> _RobotBuilder: ... - - def build(self) -> _Robot: ... - - -class _RobotFactory(Protocol): - def builder(self) -> _RobotBuilder: ... - - def from_config(self, path: str) -> _Robot: ... - - -class _MotorSpecFactory(Protocol): - def __call__(self, name: str, type: object, send_id: int, recv_id: int) -> object: ... - - -class _MockCanBusFactory(Protocol): - def __call__(self, name: str, fd: bool = False) -> object: ... - - def new_fd(self, name: str) -> object: ... - - -class _SocketCanBusFactory(Protocol): - def __call__(self, interface: str, fd: bool = False) -> object: ... - - -class _CanMotorControlModule(Protocol): - Robot: _RobotFactory - MotorSpec: _MotorSpecFactory - MockCanBus: _MockCanBusFactory - SocketCanBus: _SocketCanBusFactory - - -class _DamiaoModule(Protocol): - MotorType: object - DamiaoCodec: type[object] +try: + import can_motor_control as _can_motor_control + from can_motor_control import damiao as _damiao +except ImportError as exc: + _can_motor_control = None + _damiao = None + _can_motor_control_import_error: ImportError | None = exc +else: + _can_motor_control_import_error = None class DamiaoBindingUnavailableError(RuntimeError): pass -def _as_object(value: object) -> object: - return value - - -def _load_can_motor_control( +def _ensure_can_motor_control( *, adapter_type: str, error_type: type[RuntimeError] = DamiaoBindingUnavailableError, -) -> tuple[_CanMotorControlModule, _DamiaoModule]: - try: - import can_motor_control - from can_motor_control import damiao - except ImportError as exc: +) -> None: + if _can_motor_control_import_error is not None: raise error_type( f"The selected '{adapter_type}' adapter requires the Rust-backed " "can-motor-control Python binding in the active environment. Install " f"dimos[manipulation] before selecting adapter_type='{adapter_type}'." - ) from exc - return cast("_CanMotorControlModule", _as_object(can_motor_control)), cast( - "_DamiaoModule", _as_object(damiao) - ) + ) from _can_motor_control_import_error + + +def _dynamic_attr(value: object, name: str) -> Any: + return getattr(value, name) -def _resolve_motor_type(damiao: _DamiaoModule, motor_type: object) -> object: +def _resolve_motor_type(motor_type: object) -> object: + assert _damiao is not None if isinstance(motor_type, str): try: - return cast("object", getattr(damiao.MotorType, motor_type)) + return getattr(_damiao.MotorType, motor_type) except AttributeError as exc: raise ValueError(f"Unknown Damiao motor type {motor_type!r}") from exc if not isinstance(motor_type, int): return motor_type - for name in dir(damiao.MotorType): + for name in dir(_damiao.MotorType): if name.startswith("_"): continue - candidate = cast("object", getattr(damiao.MotorType, name)) + candidate = getattr(_damiao.MotorType, name) try: - candidate_value = int(cast("_SupportsInt", candidate)) + candidate_value = int(candidate) except (TypeError, ValueError): continue if candidate_value == motor_type: @@ -234,7 +154,7 @@ def __init__( self._control_mode = ControlMode.POSITION self._enabled = False self._last_positions: list[float] | None = None - self._pin_model: _PinocchioModel | None = None + self._pin_model = None self._pin_data: object | None = None self._address = str(address) if address is not None else "can0" self._config_path = str(config_path) if config_path is not None else None @@ -244,10 +164,8 @@ def __init__( self._use_mock_bus = use_mock_bus self._tick_deadline_us = tick_deadline_us self._state_cache_ttl_s = state_cache_ttl_s - self._can_motor_control: _CanMotorControlModule | None = None - self._damiao: _DamiaoModule | None = None - self._robot: _Robot | None = None - self._arm: _Arm | None = None + self._robot: Any | None = None + self._arm: Any | None = None self._connected = False self._state_cache: tuple[list[float], list[float], list[float]] | None = None self._state_cache_time = 0.0 @@ -323,18 +241,20 @@ def read_force_torque(self) -> list[float] | None: def connect(self) -> bool: try: - self._can_motor_control, self._damiao = _load_can_motor_control( + _ensure_can_motor_control( adapter_type=self._adapter_type, error_type=self._binding_error_type, ) - self._robot = self._build_robot() - self._robot.connect() - self._arm = self._robot[self._arm_name] - if len(self._arm) != self._dof: + robot = self._build_robot() + robot.connect() + arm = robot[self._arm_name] + if len(arm) != self._dof: raise RuntimeError( - f"can_motor_control arm group {self._arm_name!r} has {len(self._arm)} joints, " + f"can_motor_control arm group {self._arm_name!r} has {len(arm)} joints, " f"expected {self._dof}" ) + self._robot = robot + self._arm = arm self._load_gravity_model() self._connected = True self.refresh_state(force=True) @@ -350,30 +270,30 @@ def connect(self) -> bool: return False return True - def _build_robot(self) -> _Robot: - assert self._can_motor_control is not None - assert self._damiao is not None + def _build_robot(self) -> Any: + assert _can_motor_control is not None + assert _damiao is not None if self._config_path is not None: - return self._can_motor_control.Robot.from_config(self._config_path) + return _can_motor_control.Robot.from_config(self._config_path) transport = ( - self._can_motor_control.MockCanBus.new_fd(self._address) + _can_motor_control.MockCanBus.new_fd(self._address) if self._use_mock_bus and self._fd - else self._can_motor_control.MockCanBus(self._address) + else _can_motor_control.MockCanBus(self._address) if self._use_mock_bus - else self._can_motor_control.SocketCanBus(self._address, fd=self._fd) + else _can_motor_control.SocketCanBus(self._address, fd=self._fd) ) - codec = self._damiao.DamiaoCodec() + codec = _damiao.DamiaoCodec() binding_specs = [ - self._can_motor_control.MotorSpec( + _can_motor_control.MotorSpec( spec.name, - _resolve_motor_type(self._damiao, spec.type), + cast("int", _resolve_motor_type(spec.type)), spec.send_id, spec.effective_recv_id, ) for spec in self._motor_specs ] return ( - self._can_motor_control.Robot.builder() + _can_motor_control.Robot.builder() .add_bus(self._bus_name, transport, codec) .add_arm(self._arm_name, bus=self._bus_name, motors=binding_specs) .build() @@ -569,10 +489,9 @@ def _load_gravity_model(self) -> None: return import pinocchio - pinocchio_module = cast("_PinocchioModule", _as_object(pinocchio)) - - self._pin_model = pinocchio_module.buildModelFromUrdf(self._gravity_model_path) - self._pin_data = self._pin_model.createData() + build_model_from_urdf = _dynamic_attr(pinocchio, "buildModelFromUrdf") + self._pin_model = build_model_from_urdf(self._gravity_model_path) + self._pin_data = _dynamic_attr(self._pin_model, "createData")() def compute_gravity_torques(self, q: list[float]) -> list[float]: self._validate_length("q", q) @@ -580,9 +499,8 @@ def compute_gravity_torques(self, q: list[float]) -> list[float]: return [0.0] * self._dof import pinocchio - pinocchio_module = cast("_PinocchioModule", _as_object(pinocchio)) - - tau = pinocchio_module.computeGeneralizedGravity( + compute_generalized_gravity = _dynamic_attr(pinocchio, "computeGeneralizedGravity") + tau = compute_generalized_gravity( self._pin_model, self._pin_data, np.array(q, dtype=np.float64), diff --git a/dimos/hardware/manipulators/openarm_rs/test_adapter.py b/dimos/hardware/manipulators/openarm_rs/test_adapter.py index af8faeab74..aed25e04c9 100644 --- a/dimos/hardware/manipulators/openarm_rs/test_adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/test_adapter.py @@ -14,8 +14,6 @@ from __future__ import annotations -import builtins -from collections.abc import Mapping from enum import IntEnum import sys from types import ModuleType @@ -24,6 +22,7 @@ import numpy as np import pytest +import dimos.hardware.manipulators.damiao.base_adapter as damiao_base_adapter from dimos.hardware.manipulators.openarm_rs.adapter import ( OpenArmRSAdapter, OpenArmRSBindingUnavailableError, @@ -182,6 +181,9 @@ def fake_can_motor_control(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(fake_dm, "damiao", fake_damiao, raising=False) monkeypatch.setitem(sys.modules, "can_motor_control", fake_dm) monkeypatch.setitem(sys.modules, "can_motor_control.damiao", fake_damiao) + monkeypatch.setattr(damiao_base_adapter, "_can_motor_control", fake_dm) + monkeypatch.setattr(damiao_base_adapter, "_damiao", fake_damiao) + monkeypatch.setattr(damiao_base_adapter, "_can_motor_control_import_error", None) FakeRobot.last = None @@ -245,61 +247,23 @@ def test_default_motor_specs_use_binding_motor_type_values() -> None: assert robot.arm.dof == 7 -def test_can_motor_control_binding_uses_normal_import_style( - monkeypatch: pytest.MonkeyPatch, -) -> None: - imported: list[str] = [] - real_import = builtins.__import__ - - def track_can_motor_control( - name: str, - globals: Mapping[str, object] | None = None, - locals: Mapping[str, object] | None = None, - fromlist: tuple[str, ...] = (), - level: int = 0, - ) -> ModuleType: - if name.startswith("can_motor_control"): - imported.append(name) - module = real_import(name, globals, locals, fromlist, level) - if not isinstance(module, ModuleType): - raise TypeError(f"expected module import for {name}") - return module - - def fail_importlib_for_binding(name: str, package: str | None = None) -> ModuleType: - if name.startswith("can_motor_control"): - raise AssertionError("can_motor_control must use normal import syntax") - module = real_import(name, fromlist=("*",)) - if not isinstance(module, ModuleType): - raise TypeError(f"expected module import for {name}") - return module - - monkeypatch.setattr(builtins, "__import__", track_can_motor_control) - monkeypatch.setattr("importlib.import_module", fail_importlib_for_binding) +def test_can_motor_control_binding_is_loaded_at_base_module_import() -> None: adapter = OpenArmRSAdapter(use_mock_bus=True) assert adapter.connect() is True - assert imported[:2] == ["can_motor_control", "can_motor_control"] + assert vars(damiao_base_adapter)["_can_motor_control"] is sys.modules["can_motor_control"] + assert vars(damiao_base_adapter)["_damiao"] is sys.modules["can_motor_control.damiao"] def test_missing_binding_fails_only_when_selected(monkeypatch: pytest.MonkeyPatch) -> None: - real_import = builtins.__import__ monkeypatch.delitem(sys.modules, "can_motor_control", raising=False) monkeypatch.delitem(sys.modules, "can_motor_control.damiao", raising=False) - - def fail_can_motor_control( - name: str, - globals: Mapping[str, object] | None = None, - locals: Mapping[str, object] | None = None, - fromlist: tuple[str, ...] = (), - level: int = 0, - ) -> ModuleType: - if name.startswith("can_motor_control"): - raise ImportError(name) - module = real_import(name, globals, locals, fromlist, level) - if not isinstance(module, ModuleType): - raise TypeError(f"expected module import for {name}") - return module - - monkeypatch.setattr(builtins, "__import__", fail_can_motor_control) + monkeypatch.setattr(damiao_base_adapter, "_can_motor_control", None) + monkeypatch.setattr(damiao_base_adapter, "_damiao", None) + monkeypatch.setattr( + damiao_base_adapter, + "_can_motor_control_import_error", + ImportError("can_motor_control"), + ) adapter = OpenArmRSAdapter(use_mock_bus=True) with pytest.raises(OpenArmRSBindingUnavailableError, match="openarm_rs.*can-motor-control"): adapter.connect() From e555cf3c817a265e31e1fad55b0bab4377bc5a5d Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 18:39:58 -0700 Subject: [PATCH 013/110] ci: update can lib with stubs --- pyproject.toml | 2 +- stubs/can_motor_control/__init__.pyi | 45 ---------------------------- stubs/can_motor_control/damiao.pyi | 7 ----- uv.lock | 8 ++--- 4 files changed, 5 insertions(+), 57 deletions(-) delete mode 100644 stubs/can_motor_control/__init__.pyi delete mode 100644 stubs/can_motor_control/damiao.pyi diff --git a/pyproject.toml b/pyproject.toml index bf05125c0f..f04b9a7bd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -254,7 +254,7 @@ manipulation = [ "drake>=1.40.0; sys_platform != 'darwin' and platform_machine != 'aarch64'", # Hardware SDKs - "can-motor-control>=0.1.0; sys_platform == 'linux' and platform_machine == 'x86_64'", + "can-motor-control>=0.0.2; sys_platform == 'linux' and platform_machine == 'x86_64'", "piper-sdk", "pyrealsense2-extended; sys_platform != 'darwin'", "xarm-python-sdk>=1.17.0", diff --git a/stubs/can_motor_control/__init__.pyi b/stubs/can_motor_control/__init__.pyi deleted file mode 100644 index 308576a371..0000000000 --- a/stubs/can_motor_control/__init__.pyi +++ /dev/null @@ -1,45 +0,0 @@ -from numpy import float64 -from numpy.typing import NDArray - -from . import damiao as damiao - -FloatArray = NDArray[float64] - -class MotorSpec: - def __init__(self, name: str, type: object, send_id: int, recv_id: int) -> None: ... - -class MockCanBus: - def __init__(self, name: str, fd: bool = False) -> None: ... - @staticmethod - def new_fd(name: str) -> object: ... - -class SocketCanBus: - def __init__(self, interface: str, fd: bool = False) -> None: ... - -class ArmMotor: - fault: int | None - -class Arm: - def __len__(self) -> int: ... - def __getitem__(self, name: str) -> ArmMotor: ... - def refresh(self) -> None: ... - def positions(self) -> FloatArray: ... - def velocities(self) -> FloatArray: ... - def torques(self) -> FloatArray: ... - def mit_control(self, cmds: FloatArray) -> None: ... - -class RobotBuilder: - def add_bus(self, name: str, transport: object, codec: object) -> RobotBuilder: ... - def add_arm(self, name: str, *, bus: str, motors: list[object]) -> RobotBuilder: ... - def build(self) -> Robot: ... - -class Robot: - @classmethod - def builder(cls) -> RobotBuilder: ... - @classmethod - def from_config(cls, path: str) -> Robot: ... - def __getitem__(self, name: str) -> Arm: ... - def connect(self) -> None: ... - def enable(self) -> None: ... - def disable(self) -> None: ... - def tick(self, per_bus_deadline_us: int) -> None: ... diff --git a/stubs/can_motor_control/damiao.pyi b/stubs/can_motor_control/damiao.pyi deleted file mode 100644 index e67c598608..0000000000 --- a/stubs/can_motor_control/damiao.pyi +++ /dev/null @@ -1,7 +0,0 @@ -class MotorType: - DM4310: object - DM4340: object - DM8006: object - -class DamiaoCodec: - def __init__(self) -> None: ... diff --git a/uv.lock b/uv.lock index 7d46b8bd3b..8a85ad9dd2 100644 --- a/uv.lock +++ b/uv.lock @@ -570,15 +570,15 @@ wheels = [ [[package]] name = "can-motor-control" -version = "0.1.0" +version = "0.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/8d/b028431070f11f0b075d9b07f43f16aa134fe0bf50198d5d453f3695be7f/can_motor_control-0.1.0.tar.gz", hash = "sha256:780b2db0757721533f320c679fd90c0465aabfc4b8fd88c614ce2075778870c9", size = 114951, upload-time = "2026-06-06T04:11:04.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/c1/d4a796df3a503c5b15d188fe65793631038db2f59f51c7863f8d776e7f3c/can_motor_control-0.0.2.tar.gz", hash = "sha256:7ca4942c680244ca8fe86f5dbb0cb870a4c36d786827ece1b7f2e56dfe186ee7", size = 114974, upload-time = "2026-06-07T01:33:14.484Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/80/921e6e630d23f7fb98d22a0ce211b4d42c0c30f2f9a5bac749898bab6565/can_motor_control-0.1.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:23fb8bd812d34cc306a43efabb484a1bb452c8d89ecf09299d9ce827e2e33234", size = 509924, upload-time = "2026-06-06T04:11:02.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/76d8389a658795e1fe7e9f771e1179f25a8fb75f20c763d90719d9e0b603/can_motor_control-0.0.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:5f1a993dba3ee2efc6e92f9e7a2ca5503d9fb2c2c436c7377e9e5a46b31aa94e", size = 510110, upload-time = "2026-06-07T01:33:12.825Z" }, ] [[package]] @@ -2352,7 +2352,7 @@ requires-dist = [ { name = "annotation-protocol", specifier = ">=1.4.0" }, { name = "anthropic", marker = "extra == 'agents'", specifier = ">=0.19.0" }, { name = "bleak", specifier = ">=3.0.2" }, - { name = "can-motor-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'", specifier = ">=0.1.0" }, + { name = "can-motor-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'", specifier = ">=0.0.2" }, { name = "catkin-pkg", marker = "extra == 'misc'" }, { name = "cerebras-cloud-sdk", marker = "extra == 'misc'" }, { name = "colorlog", specifier = "==6.9.0" }, From c3bf25ac408b1a8540530caa1e90a6d4b919c059 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 7 Jun 2026 21:24:52 -0700 Subject: [PATCH 014/110] fix: simplify openarm rs bringup --- .../current_position_hold_task/__init__.py | 13 -- .../__registry__.py | 17 --- .../current_position_hold_task.py | 122 ------------------ dimos/control/test_control.py | 68 +--------- .../manipulators/damiao/base_adapter.py | 5 + .../manipulators/openarm_rs/test_adapter.py | 10 +- dimos/robot/all_blueprints.py | 2 +- .../robot/manipulators/openarm/blueprints.py | 33 ++--- .../manipulation/openarm_integration.md | 39 +++--- 9 files changed, 52 insertions(+), 257 deletions(-) delete mode 100644 dimos/control/tasks/current_position_hold_task/__init__.py delete mode 100644 dimos/control/tasks/current_position_hold_task/__registry__.py delete mode 100644 dimos/control/tasks/current_position_hold_task/current_position_hold_task.py diff --git a/dimos/control/tasks/current_position_hold_task/__init__.py b/dimos/control/tasks/current_position_hold_task/__init__.py deleted file mode 100644 index bc1a2ce5cc..0000000000 --- a/dimos/control/tasks/current_position_hold_task/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/dimos/control/tasks/current_position_hold_task/__registry__.py b/dimos/control/tasks/current_position_hold_task/__registry__.py deleted file mode 100644 index a2f1ec89db..0000000000 --- a/dimos/control/tasks/current_position_hold_task/__registry__.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -TASK_FACTORIES = { - "current_position_hold": "dimos.control.tasks.current_position_hold_task.current_position_hold_task:create_task", -} diff --git a/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py b/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py deleted file mode 100644 index 264ae773a9..0000000000 --- a/dimos/control/tasks/current_position_hold_task/current_position_hold_task.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Current-position hold task for hardware write-path bring-up. - -This task continuously outputs the current measured joint positions as -SERVO_POSITION commands. It is intended for controlled hardware tests where the -adapter should actively write hold frames without requiring an external -trajectory command. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from dimos.control.task import ( - BaseControlTask, - ControlMode, - CoordinatorState, - JointCommandOutput, - ResourceClaim, -) -from dimos.protocol.service.spec import BaseConfig -from dimos.utils.logging_config import setup_logger - -logger = setup_logger() - - -@dataclass -class CurrentPositionHoldTaskConfig: - joint_names: list[str] - priority: int = 5 - - -class CurrentPositionHoldTask(BaseControlTask): - """Hold the current measured joint positions every coordinator tick.""" - - def __init__(self, name: str, config: CurrentPositionHoldTaskConfig) -> None: - if not config.joint_names: - raise ValueError(f"CurrentPositionHoldTask '{name}' requires at least one joint") - self._name = name - self._config = config - self._joint_names = frozenset(config.joint_names) - self._joint_names_list = list(config.joint_names) - self._active = False - logger.info(f"CurrentPositionHoldTask {name} initialized for joints: {config.joint_names}") - - @property - def name(self) -> str: - return self._name - - def claim(self) -> ResourceClaim: - return ResourceClaim( - joints=self._joint_names, - priority=self._config.priority, - mode=ControlMode.SERVO_POSITION, - ) - - def is_active(self) -> bool: - return self._active - - def compute(self, state: CoordinatorState) -> JointCommandOutput | None: - if not self._active: - return None - positions: list[float] = [] - for joint_name in self._joint_names_list: - position = state.joints.get_position(joint_name) - if position is None: - return None - positions.append(position) - return JointCommandOutput( - joint_names=self._joint_names_list, - positions=positions, - mode=ControlMode.SERVO_POSITION, - ) - - def on_preempted(self, by_task: str, joints: frozenset[str]) -> None: - if joints & self._joint_names: - logger.warning( - f"CurrentPositionHoldTask {self._name} preempted by {by_task} on joints {joints}" - ) - - def start(self) -> None: - self._active = True - logger.info(f"CurrentPositionHoldTask {self._name} started") - - def stop(self) -> None: - self._active = False - logger.info(f"CurrentPositionHoldTask {self._name} stopped") - - -class CurrentPositionHoldTaskParams(BaseConfig): - pass - - -def create_task(cfg: Any, hardware: Any) -> CurrentPositionHoldTask: - CurrentPositionHoldTaskParams.model_validate(cfg.params) - return CurrentPositionHoldTask( - cfg.name, - CurrentPositionHoldTaskConfig( - joint_names=cfg.joint_names, - priority=cfg.priority, - ), - ) - - -__all__ = [ - "CurrentPositionHoldTask", - "CurrentPositionHoldTaskConfig", -] diff --git a/dimos/control/test_control.py b/dimos/control/test_control.py index 8e1c665710..134a917374 100644 --- a/dimos/control/test_control.py +++ b/dimos/control/test_control.py @@ -18,23 +18,21 @@ import threading import time +from typing import cast from unittest.mock import MagicMock import pytest -from dimos.control.components import HardwareComponent, HardwareType, make_joints +from dimos.control.components import HardwareComponent, HardwareType, TaskName, make_joints from dimos.control.hardware_interface import ConnectedHardware from dimos.control.task import ( ControlMode, + ControlTask, CoordinatorState, JointCommandOutput, JointStateSnapshot, ResourceClaim, ) -from dimos.control.tasks.current_position_hold_task.current_position_hold_task import ( - CurrentPositionHoldTask, - CurrentPositionHoldTaskConfig, -) from dimos.control.tasks.trajectory_task.trajectory_task import ( JointTrajectoryTask, JointTrajectoryTaskConfig, @@ -407,7 +405,7 @@ def test_tick_loop_starts_and_stops(self, mock_adapter): ) hw = ConnectedHardware(mock_adapter, component) hardware = {"arm": hw} - tasks: dict = {} + tasks: dict[TaskName, ControlTask] = {} joint_to_hardware = {f"arm/joint{i + 1}": "arm" for i in range(6)} tick_loop = TickLoop( @@ -450,7 +448,7 @@ def test_tick_loop_calls_compute(self, mock_adapter): mode=ControlMode.POSITION, ) - tasks = {"test_task": mock_task} + tasks: dict[TaskName, ControlTask] = {"test_task": cast("ControlTask", mock_task)} joint_to_hardware = {f"arm/joint{i + 1}": "arm" for i in range(6)} tick_loop = TickLoop( @@ -484,7 +482,7 @@ def test_full_trajectory_execution(self, mock_adapter): priority=10, ) traj_task = JointTrajectoryTask(name="traj_arm", config=config) - tasks = {"traj_arm": traj_task} + tasks: dict[TaskName, ControlTask] = {"traj_arm": traj_task} joint_to_hardware = {f"arm/joint{i + 1}": "arm" for i in range(6)} @@ -521,57 +519,3 @@ def test_full_trajectory_execution(self, mock_adapter): assert traj_task.get_state() == TrajectoryState.COMPLETED assert mock_adapter.write_joint_positions.call_count > 0 - - -class TestCurrentPositionHoldTask: - def test_initial_state(self) -> None: - task = CurrentPositionHoldTask( - name="hold", - config=CurrentPositionHoldTaskConfig( - joint_names=["arm/joint1", "arm/joint2"], - priority=5, - ), - ) - assert not task.is_active() - claim = task.claim() - assert claim.joints == frozenset({"arm/joint1", "arm/joint2"}) - assert claim.priority == 5 - assert claim.mode == ControlMode.SERVO_POSITION - - def test_outputs_current_positions_when_started(self) -> None: - task = CurrentPositionHoldTask( - name="hold", - config=CurrentPositionHoldTaskConfig( - joint_names=["arm/joint1", "arm/joint2"], - priority=5, - ), - ) - task.start() - state = CoordinatorState( - joints=JointStateSnapshot( - joint_positions={"arm/joint1": 0.1, "arm/joint2": -0.2}, - ), - t_now=1.0, - dt=0.01, - ) - output = task.compute(state) - assert output is not None - assert output.mode == ControlMode.SERVO_POSITION - assert output.joint_names == ["arm/joint1", "arm/joint2"] - assert output.positions == pytest.approx([0.1, -0.2]) - - def test_skips_output_until_all_positions_exist(self) -> None: - task = CurrentPositionHoldTask( - name="hold", - config=CurrentPositionHoldTaskConfig( - joint_names=["arm/joint1", "arm/joint2"], - priority=5, - ), - ) - task.start() - state = CoordinatorState( - joints=JointStateSnapshot(joint_positions={"arm/joint1": 0.1}), - t_now=1.0, - dt=0.01, - ) - assert task.compute(state) is None diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index 4539bd2184..0501f1ddf1 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -470,6 +470,11 @@ def write_enable(self, enable: bool) -> bool: logger.error(f"{type(self).__name__} {self._hardware_id} enable={enable} failed: {exc}") return False self._enabled = enable + if enable: + positions = self.read_joint_positions() + if not self.write_joint_positions(positions): + logger.error(f"{type(self).__name__} {self._hardware_id} startup hold failed") + return False return True def write_clear_errors(self) -> bool: diff --git a/dimos/hardware/manipulators/openarm_rs/test_adapter.py b/dimos/hardware/manipulators/openarm_rs/test_adapter.py index aed25e04c9..f38f16cc01 100644 --- a/dimos/hardware/manipulators/openarm_rs/test_adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/test_adapter.py @@ -274,10 +274,14 @@ def test_lifecycle_read_write_disable() -> None: assert adapter.connect() is True assert adapter.write_enable(True) is True assert adapter.read_enabled() is True - assert adapter.read_joint_positions() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) - assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True robot = FakeRobot.last assert robot is not None + startup_cmd = robot.arm.mit_commands[-1] + assert startup_cmd[:, 2].tolist() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + assert startup_cmd[:, 3].tolist() == pytest.approx([0.0] * 7) + assert startup_cmd[:, 4].tolist() == pytest.approx([0.0] * 7) + assert adapter.read_joint_positions() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True assert robot.arm.position_commands == [] assert robot.arm.velocity_commands == [] cmd = robot.arm.mit_commands[-1] @@ -359,7 +363,7 @@ def test_velocity_mode_and_commands_are_unsupported() -> None: assert adapter.write_joint_velocities([0.2] * 7) is False robot = FakeRobot.last assert robot is not None - assert robot.arm.mit_commands == [] + assert len(robot.arm.mit_commands) == 1 assert robot.arm.velocity_commands == [] assert adapter.get_control_mode() == ControlMode.POSITION diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index dd6a2174a3..5b9846f966 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -34,7 +34,6 @@ "coordinator-openarm-mock": "dimos.robot.manipulators.openarm.blueprints:coordinator_openarm_mock", "coordinator-openarm-right": "dimos.robot.manipulators.openarm.blueprints:coordinator_openarm_right", "coordinator-openarm-rs": "dimos.robot.manipulators.openarm.blueprints:coordinator_openarm_rs", - "coordinator-openarm-rs-hold-test": "dimos.robot.manipulators.openarm.blueprints:coordinator_openarm_rs_hold_test", "coordinator-piper": "dimos.control.blueprints.basic:coordinator_piper", "coordinator-piper-xarm": "dimos.control.blueprints.dual:coordinator_piper_xarm", "coordinator-servo-xarm6": "dimos.control.blueprints.teleop:coordinator_servo_xarm6", @@ -73,6 +72,7 @@ "mid360-fastlio-voxels-native": "dimos.hardware.sensors.lidar.fastlio2.fastlio_blueprints:mid360_fastlio_voxels_native", "openarm-mock-planner-coordinator": "dimos.robot.manipulators.openarm.blueprints:openarm_mock_planner_coordinator", "openarm-planner-coordinator": "dimos.robot.manipulators.openarm.blueprints:openarm_planner_coordinator", + "openarm-rs-planner-coordinator": "dimos.robot.manipulators.openarm.blueprints:openarm_rs_planner_coordinator", "path-planner-eval": "dimos.navigation.nav_3d.evaluator.blueprints:path_planner_eval", "teleop-phone": "dimos.teleop.phone.blueprints:teleop_phone", "teleop-phone-go2": "dimos.teleop.phone.blueprints:teleop_phone_go2", diff --git a/dimos/robot/manipulators/openarm/blueprints.py b/dimos/robot/manipulators/openarm/blueprints.py index b9b8f6ab1d..90f20041c6 100644 --- a/dimos/robot/manipulators/openarm/blueprints.py +++ b/dimos/robot/manipulators/openarm/blueprints.py @@ -16,7 +16,7 @@ from __future__ import annotations -from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.control.coordinator import ControlCoordinator from dimos.core.coordination.blueprints import autoconnect from dimos.core.transport import LCMTransport from dimos.manipulation.manipulation_module import ManipulationModule @@ -84,9 +84,6 @@ address=RIGHT_CAN, adapter_kwargs=_OPENARM_RS_ADAPTER_KWARGS, ) -_openarm_rs_joint_names = _openarm_rs_hw.joint_names -if _openarm_rs_joint_names is None: - raise ValueError("OpenArm RS hardware config requires joint names") coordinator_openarm_left = ControlCoordinator.blueprint( hardware=[_left_hw.to_hardware_component()], @@ -121,27 +118,23 @@ coordinator_openarm_rs = ControlCoordinator.blueprint( hardware=[_openarm_rs_hw.to_hardware_component()], - tasks=[_openarm_rs_hw.to_task_config(task_name="traj_openarm_rs")], + tasks=[_openarm_rs_hw.to_task_config()], ).transports( { ("joint_state", JointState): LCMTransport("/coordinator/joint_state", JointState), } ) -# Hardware bring-up test: immediately writes current-position SERVO_POSITION -# commands so the openarm_rs adapter emits MIT hold frames with gravity -# feed-forward. Use cautiously; this is an active write-path test. -coordinator_openarm_rs_hold_test = ControlCoordinator.blueprint( - hardware=[_openarm_rs_hw.to_hardware_component()], - tasks=[ - TaskConfig( - name="hold_openarm_rs", - type="current_position_hold", - joint_names=_openarm_rs_joint_names, - priority=5, - auto_start=True, - ), - ], +openarm_rs_planner_coordinator = autoconnect( + ManipulationModule.blueprint( + robots=[_openarm_rs_hw.to_robot_model_config()], + planning_timeout=10.0, + enable_viz=True, + ), + ControlCoordinator.blueprint( + hardware=[_openarm_rs_hw.to_hardware_component()], + tasks=[_openarm_rs_hw.to_task_config()], + ), ).transports( { ("joint_state", JointState): LCMTransport("/coordinator/joint_state", JointState), @@ -259,9 +252,9 @@ "coordinator_openarm_mock", "coordinator_openarm_right", "coordinator_openarm_rs", - "coordinator_openarm_rs_hold_test", "keyboard_teleop_openarm", "keyboard_teleop_openarm_mock", "openarm_mock_planner_coordinator", "openarm_planner_coordinator", + "openarm_rs_planner_coordinator", ] diff --git a/docs/capabilities/manipulation/openarm_integration.md b/docs/capabilities/manipulation/openarm_integration.md index 862e3a2002..d75f7ce0a0 100644 --- a/docs/capabilities/manipulation/openarm_integration.md +++ b/docs/capabilities/manipulation/openarm_integration.md @@ -122,7 +122,8 @@ The register is persistent across power cycles, so you only need this once per m | `coordinator-openarm-bimanual` | Both arms, real hardware, no planner. | | `openarm-planner-coordinator` | **Main usable blueprint** — Drake planner + both arms on real hardware. | | `keyboard-teleop-openarm-mock` / `keyboard-teleop-openarm` | Single-arm Cartesian IK + pygame keyboard, mock / real. | -| `coordinator-openarm-rs` | Opt-in single-arm coordinator path using `adapter_type="openarm_rs"`; gravity feed-forward is enabled in the adapter by default. | +| `coordinator-openarm-rs` | Opt-in single-arm coordinator path using the new `adapter_type="openarm_rs"` driver, without the planner. | +| `openarm-rs-planner-coordinator` | **New driver usable blueprint** — `ManipulationModule` + `ControlCoordinator` + `openarm_rs` on one arm. | **Safety before hot-plugging hardware:** hold the arms before starting. On connect, the adapter enables all motors and sends gravity-comp holds — the arms go slightly stiff but don't leap. Ctrl-C to cleanly disable and exit. @@ -139,28 +140,26 @@ dimos run coordinator-openarm-left dimos run openarm-planner-coordinator ``` -For the `openarm_rs` binding path, stage validation before trajectory control: binding mock or vcan, one motor enable/read, one motor low-rate hold, full-arm state monitor, adapter gravity compensation, then trajectory-control validation. +For the `openarm_rs` binding path, stage validation before trajectory control: binding mock or vcan, one motor enable/read, full-arm state monitor, adapter gravity compensation, then trajectory-control validation. ```bash # requires the can-motor-control binding from dimos[manipulation] sudo MODE=fd ./dimos/robot/manipulators/openarm/scripts/openarm_can_up.sh can0 -# read/trajectory coordinator: writes only after a task receives a command +# low-level coordinator: enables the arm and sends one current-position hold frame dimos run coordinator-openarm-rs -# active write-path test: immediately holds current q with MIT + gravity feed-forward -dimos run coordinator-openarm-rs-hold-test +# manipulation workflow: startup hold, then plan, preview, and execute through the new driver +dimos run openarm-rs-planner-coordinator ``` -The hold-test blueprint auto-starts a current-position hold task. It reads the current joints and writes those same positions every coordinator tick so `OpenArmRSAdapter` emits MIT position frames with gravity feed-forward. Use it only when the arm is supported and you intentionally want an active hardware write-path test. - `OpenArmRSAdapter` opens CAN-FD by default (`canfd=True`) and computes model gravity feed-forward in-place when `gravity_comp=True` (the OpenArm blueprint default). It intentionally supports position and effort semantics only: position commands are sent as MIT commands with preset `kp/kd` gains and optional gravity feed-forward, while effort/gravity-only commands use `kp=0` so the arm does not hold a target pose. Velocity commands are rejected because nonzero gains make MIT commands maintain the supplied `q`. Set `gravity_comp=False` in adapter kwargs to keep position MIT commands but omit model feed-forward torque. Meshcat will appear at http://localhost:7000. -### 5. Drive the arms from the manipulation client +### 5. Drive the new driver from the manipulation client -With `openarm-planner-coordinator` running in one terminal, open a second terminal and start the REPL client: +With `openarm-rs-planner-coordinator` running in one terminal, open a second terminal and start the REPL client: ```bash python -i -m dimos.manipulation.planning.examples.manipulation_client @@ -170,7 +169,7 @@ This gives you an interactive Python prompt with these functions: | Function | Purpose | |---|---| -| `robots()` | List configured robots (here: `["left_arm", "right_arm"]`) | +| `robots()` | List configured robots (for `openarm-rs-planner-coordinator`: `["arm"]`) | | `joints(robot_name)` | Read current joint positions (7 floats) | | `ee(robot_name)` | Read current end-effector pose | | `state()` | Module state: `IDLE`, `PLANNING`, `EXECUTING`, `FAULT`, etc. | @@ -185,16 +184,16 @@ This gives you an interactive Python prompt with these functions: ```python skip >>> robots() -['left_arm', 'right_arm'] +['arm'] ->>> joints(robot_name="left_arm") +>>> joints() [0.02, -0.01, -0.13, 0.15, 0.17, -0.07, 0.10] >>> # One-liner: plan → preview in Meshcat → execute on hardware ->>> plan([0.3, 0, 0, 0, 0, 0, 0], robot_name="left_arm") and preview(robot_name="left_arm") and execute(robot_name="left_arm") +>>> plan([0.3, 0, 0, 0, 0, 0, 0]) and preview() and execute() True ->>> joints(robot_name="left_arm") +>>> joints() [0.30, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00] # arm is now at the commanded pose ``` @@ -207,7 +206,9 @@ If you ever get stuck in a `FAULT` state (e.g. an invalid plan was sent), reset 'Reset to IDLE — ready for new commands' ``` -#### Example session — bimanual +#### Example session — bimanual legacy `openarm` blueprint + +Use this pattern with `openarm-planner-coordinator`, which configures `left_arm` and `right_arm`. The new `openarm-rs-planner-coordinator` blueprint is intentionally single-arm while the binding-backed driver path is being validated. ```python skip >>> # Move both arms to mirrored poses @@ -222,10 +223,10 @@ Each arm plans and executes independently — the coordinator runs both trajecto #### Example session — Cartesian target ```python skip ->>> ee(robot_name="left_arm") # see where the EE currently is ->>> plan_pose(0.1, 0.3, 0.5, robot_name="left_arm") and preview(robot_name="left_arm") +>>> ee() # see where the EE currently is +>>> plan_pose(0.1, 0.3, 0.5) and preview() True ->>> execute(robot_name="left_arm") +>>> execute() True ``` @@ -253,7 +254,7 @@ LEFT_CAN = "can1" RIGHT_CAN = "can0" ``` -No other code changes are needed. The `coordinator-openarm-rs` blueprint currently targets `RIGHT_CAN` (`can0`) and passes `canfd=True`; use `sudo MODE=fd ... openarm_can_up.sh can0` before running it. +No other code changes are needed. The `coordinator-openarm-rs` and `openarm-rs-planner-coordinator` blueprints currently target `RIGHT_CAN` (`can0`) and pass `canfd=True`; use `sudo MODE=fd ... openarm_can_up.sh can0` before running either one. ### Gain tuning (MIT kp/kd) From b5a0e2cce96317ae73e17ed8f34805e25bcf3027 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 7 Jun 2026 22:04:55 -0700 Subject: [PATCH 015/110] fix: harden openarm rs adapter --- .../hardware/manipulators/damiao/__init__.py | 26 ------------- .../manipulators/damiao/base_adapter.py | 9 ++++- .../manipulators/openarm_rs/__init__.py | 18 --------- .../manipulators/openarm_rs/adapter.py | 17 +++++++-- .../manipulators/openarm_rs/test_adapter.py | 37 +++++++++++++++++++ 5 files changed, 57 insertions(+), 50 deletions(-) delete mode 100644 dimos/hardware/manipulators/damiao/__init__.py delete mode 100644 dimos/hardware/manipulators/openarm_rs/__init__.py diff --git a/dimos/hardware/manipulators/damiao/__init__.py b/dimos/hardware/manipulators/damiao/__init__.py deleted file mode 100644 index 99975f5fba..0000000000 --- a/dimos/hardware/manipulators/damiao/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from dimos.hardware.manipulators.damiao.base_adapter import ( - DamiaoArmAdapterBase, - DamiaoBindingUnavailableError, -) -from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec - -__all__ = [ - "DamiaoArmAdapterBase", - "DamiaoArmSpec", - "DamiaoBindingUnavailableError", - "DamiaoMotorSpec", -] diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index 0501f1ddf1..9528f05362 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -14,6 +14,7 @@ from __future__ import annotations +import importlib from pathlib import Path import time from typing import Any, cast @@ -33,8 +34,8 @@ _damiao: Any | None try: - import can_motor_control as _can_motor_control - from can_motor_control import damiao as _damiao + _can_motor_control = importlib.import_module("can_motor_control") + _damiao = importlib.import_module("can_motor_control.damiao") except ImportError as exc: _can_motor_control = None _damiao = None @@ -487,6 +488,10 @@ def write_clear_errors(self) -> bool: logger.error(f"{type(self).__name__} {self._hardware_id} clear errors failed: {exc}") return False self._enabled = True + positions = self.read_joint_positions() + if not self.write_joint_positions(positions): + logger.error(f"{type(self).__name__} {self._hardware_id} clear-error hold failed") + return False return True def _load_gravity_model(self) -> None: diff --git a/dimos/hardware/manipulators/openarm_rs/__init__.py b/dimos/hardware/manipulators/openarm_rs/__init__.py deleted file mode 100644 index 700c75b188..0000000000 --- a/dimos/hardware/manipulators/openarm_rs/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from dimos.hardware.manipulators.openarm_rs.adapter import OpenArmRSAdapter - -__all__ = ["OpenArmRSAdapter"] diff --git a/dimos/hardware/manipulators/openarm_rs/adapter.py b/dimos/hardware/manipulators/openarm_rs/adapter.py index 8017eae80f..99d6f4337e 100644 --- a/dimos/hardware/manipulators/openarm_rs/adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/adapter.py @@ -45,8 +45,10 @@ class OpenArmRSAdapter(DamiaoArmAdapterBase): DamiaoMotorSpec("joint6", "DM4310", 0x06, 0x16), DamiaoMotorSpec("joint7", "DM4310", 0x07, 0x17), ) - _DEFAULT_POSITION_LOWER: tuple[float, ...] = (-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50) - _DEFAULT_POSITION_UPPER: tuple[float, ...] = (1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50) + _POSITION_LOWER_LEFT: tuple[float, ...] = (-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50) + _POSITION_UPPER_LEFT: tuple[float, ...] = (1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50) + _POSITION_LOWER_RIGHT: tuple[float, ...] = (-1.35, -0.15, -1.50, -0.01, -1.50, -0.75, -1.50) + _POSITION_UPPER_RIGHT: tuple[float, ...] = (3.45, 3.30, 1.50, 2.40, 1.50, 0.75, 1.50) _DEFAULT_VELOCITY_MAX: tuple[float, ...] = (45.0, 45.0, 8.0, 8.0, 30.0, 30.0, 30.0) _DEFAULT_KP: tuple[float, ...] = (70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0) _DEFAULT_KD: tuple[float, ...] = (2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5) @@ -62,6 +64,7 @@ def __init__( bus_name: str = "can", fd: bool | None = None, canfd: bool = True, + side: str = "left", use_mock_bus: bool = False, motor_specs: list[dict[str, object] | DamiaoMotorSpec] | None = None, position_lower: list[float] | None = None, @@ -78,6 +81,8 @@ def __init__( ) -> None: if dof != len(self._DEFAULT_OPENARM_MOTORS): raise ValueError(f"OpenArmRSAdapter only supports 7 DOF (got {dof})") + if side not in ("left", "right"): + raise ValueError(f"side must be 'left' or 'right', got {side!r}") if motor_specs is not None: raise ValueError("openarm_rs is OpenArm-only and does not accept custom motor_specs") if position_lower is not None or position_upper is not None or velocity_max is not None: @@ -89,8 +94,12 @@ def __init__( vendor="Enactic", model="OpenArm RS v10", motors=self._DEFAULT_OPENARM_MOTORS, - position_lower=self._DEFAULT_POSITION_LOWER, - position_upper=self._DEFAULT_POSITION_UPPER, + position_lower=self._POSITION_LOWER_LEFT + if side == "left" + else self._POSITION_LOWER_RIGHT, + position_upper=self._POSITION_UPPER_LEFT + if side == "left" + else self._POSITION_UPPER_RIGHT, velocity_max=self._DEFAULT_VELOCITY_MAX, kp=kp if kp is not None else self._DEFAULT_KP, kd=kd if kd is not None else self._DEFAULT_KD, diff --git a/dimos/hardware/manipulators/openarm_rs/test_adapter.py b/dimos/hardware/manipulators/openarm_rs/test_adapter.py index f38f16cc01..7e3042522c 100644 --- a/dimos/hardware/manipulators/openarm_rs/test_adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/test_adapter.py @@ -203,6 +203,29 @@ def test_defaults_match_openarm_ros2_hardware_presets() -> None: assert adapter._kd == pytest.approx([2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5]) +def test_side_selects_openarm_joint_limits() -> None: + left = OpenArmRSAdapter(side="left", use_mock_bus=True) + right = OpenArmRSAdapter(side="right", use_mock_bus=True) + + assert left.get_limits().position_lower == pytest.approx( + [-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50] + ) + assert left.get_limits().position_upper == pytest.approx( + [1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50] + ) + assert right.get_limits().position_lower == pytest.approx( + [-1.35, -0.15, -1.50, -0.01, -1.50, -0.75, -1.50] + ) + assert right.get_limits().position_upper == pytest.approx( + [3.45, 3.30, 1.50, 2.40, 1.50, 0.75, 1.50] + ) + + +def test_invalid_side_is_rejected() -> None: + with pytest.raises(ValueError, match="side must be 'left' or 'right'"): + OpenArmRSAdapter(side="middle", use_mock_bus=True) + + def test_canfd_enabled_by_default_for_mock_and_socket_buses() -> None: mock_adapter = OpenArmRSAdapter(use_mock_bus=True) assert mock_adapter.connect() is True @@ -293,6 +316,20 @@ def test_lifecycle_read_write_disable() -> None: assert robot.disabled_count >= 1 +def test_clear_errors_sends_current_position_hold() -> None: + adapter = OpenArmRSAdapter(use_mock_bus=True, gravity_comp=False) + assert adapter.connect() is True + robot = FakeRobot.last + assert robot is not None + + assert adapter.write_clear_errors() is True + + cmd = robot.arm.mit_commands[-1] + assert cmd[:, 2].tolist() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + assert cmd[:, 3].tolist() == pytest.approx([0.0] * 7) + assert cmd[:, 4].tolist() == pytest.approx([0.0] * 7) + + def test_state_reads_share_one_tick() -> None: adapter = OpenArmRSAdapter(use_mock_bus=True, state_cache_ttl_s=10.0) assert adapter.connect() is True From 13618f1e7ae975c2e51fc3b18d60370e0fe584aa Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:28:06 +0300 Subject: [PATCH 016/110] refactor: drop unused FloatArray alias The FloatArray alias and its numpy.typing.NDArray import were never referenced. Remove both. --- dimos/hardware/manipulators/damiao/base_adapter.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index 9528f05362..cfc56412e8 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -20,7 +20,6 @@ from typing import Any, cast import numpy as np -from numpy.typing import NDArray from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec from dimos.hardware.manipulators.spec import ControlMode, JointLimits, ManipulatorInfo @@ -28,8 +27,6 @@ logger = setup_logger() -FloatArray = NDArray[np.float64] - _can_motor_control: Any | None _damiao: Any | None From 8b9468f05b1a394006ca2ae1a1234bf62010993b Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:28:37 +0300 Subject: [PATCH 017/110] refactor: drop unused OpenArmRSMotorSpecConfig alias The OpenArmRSMotorSpecConfig alias for DamiaoMotorSpec was only referenced by its own __all__ entry; nothing imports it. Remove both. --- dimos/hardware/manipulators/openarm_rs/adapter.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dimos/hardware/manipulators/openarm_rs/adapter.py b/dimos/hardware/manipulators/openarm_rs/adapter.py index 99d6f4337e..76225c05af 100644 --- a/dimos/hardware/manipulators/openarm_rs/adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/adapter.py @@ -26,8 +26,6 @@ if TYPE_CHECKING: from dimos.hardware.manipulators.registry import AdapterRegistry -OpenArmRSMotorSpecConfig = DamiaoMotorSpec - class OpenArmRSBindingUnavailableError(DamiaoBindingUnavailableError): pass @@ -128,6 +126,5 @@ def register(registry: AdapterRegistry) -> None: __all__ = [ "OpenArmRSAdapter", "OpenArmRSBindingUnavailableError", - "OpenArmRSMotorSpecConfig", "register", ] From 872fc4395dc9daea888f103d7c6fa6c6d854916d Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:28:54 +0300 Subject: [PATCH 018/110] refactor: hoist time import in openarm adapter test Move 'import time' out of FakeState.__init__ to the module top, per the imports-at-top convention. FakeState.timestamp is still read by the adapter's staleness check, so it stays. --- dimos/hardware/manipulators/openarm/test_adapter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dimos/hardware/manipulators/openarm/test_adapter.py b/dimos/hardware/manipulators/openarm/test_adapter.py index 2f15ef1ee1..66eb87cb69 100644 --- a/dimos/hardware/manipulators/openarm/test_adapter.py +++ b/dimos/hardware/manipulators/openarm/test_adapter.py @@ -14,6 +14,7 @@ from __future__ import annotations +import time from unittest.mock import MagicMock import pytest @@ -24,8 +25,6 @@ class FakeState: def __init__(self, index: int) -> None: - import time - self.q = 0.1 * index self.dq = 0.2 * index self.tau = 0.3 * index From cc738dc68e7ca8e7696fa4126048195b24c796a5 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:29:13 +0300 Subject: [PATCH 019/110] refactor: stop restating openarm_rs adapter defaults in blueprint gravity_comp=True and canfd=True are already the OpenArmRSAdapter defaults. The blueprint should only specify what differs, so keep just gravity_model_path. --- dimos/robot/manipulators/openarm/blueprints.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/dimos/robot/manipulators/openarm/blueprints.py b/dimos/robot/manipulators/openarm/blueprints.py index 90f20041c6..ecd60dd39d 100644 --- a/dimos/robot/manipulators/openarm/blueprints.py +++ b/dimos/robot/manipulators/openarm/blueprints.py @@ -59,12 +59,10 @@ # replaced / factory-reset). AUTO_SET_MIT_MODE = True +# gravity_comp and canfd are already True by default on OpenArmRSAdapter, so +# the blueprint only overrides the gravity model path. _ADAPTER_KWARGS = {"auto_set_mit_mode": AUTO_SET_MIT_MODE} -_OPENARM_RS_ADAPTER_KWARGS = { - "gravity_model_path": OPENARM_V10_RIGHT_MODEL, - "gravity_comp": True, - "canfd": True, -} +_OPENARM_RS_ADAPTER_KWARGS = {"gravity_model_path": OPENARM_V10_RIGHT_MODEL} _left_hw = _openarm( side="left", address=LEFT_CAN, From 40dc78e27ba1fe380667581843dff73775a24acb Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:29:32 +0300 Subject: [PATCH 020/110] docs: explain lazy pinocchio imports in damiao base adapter Per the imports convention, a lazy import of a heavy optional dep needs a comment saying why. Add it to both gravity-model import sites. --- dimos/hardware/manipulators/damiao/base_adapter.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index cfc56412e8..701c098999 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -494,6 +494,8 @@ def write_clear_errors(self) -> bool: def _load_gravity_model(self) -> None: if not self._gravity_comp or self._gravity_model_path is None: return + # Lazy import: pinocchio is a heavy optional dep only needed when + # gravity compensation is enabled, so keep it out of module import. import pinocchio build_model_from_urdf = _dynamic_attr(pinocchio, "buildModelFromUrdf") @@ -504,6 +506,8 @@ def compute_gravity_torques(self, q: list[float]) -> list[float]: self._validate_length("q", q) if self._pin_model is None or self._pin_data is None: return [0.0] * self._dof + # Lazy import: pinocchio is a heavy optional dep only needed when + # gravity compensation is enabled, so keep it out of module import. import pinocchio compute_generalized_gravity = _dynamic_attr(pinocchio, "computeGeneralizedGravity") From 1f9078d51f6bf7b042d916446d10f1d67525c9b8 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:30:16 +0300 Subject: [PATCH 021/110] refactor: share tick/cache defaults via named constants tick_deadline_us=1_000 and state_cache_ttl_s=0.002 were unnamed magic numbers duplicated as defaults in both the base and the openarm_rs subclass. Hoist them to _DEFAULT_TICK_DEADLINE_US and _DEFAULT_STATE_CACHE_TTL_S so the two can't drift. --- dimos/hardware/manipulators/damiao/base_adapter.py | 9 +++++++-- dimos/hardware/manipulators/openarm_rs/adapter.py | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index 701c098999..6a58bb7e4f 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -27,6 +27,11 @@ logger = setup_logger() +# Shared adapter defaults. Subclasses reference these so the base and its +# subclasses can't drift apart. +_DEFAULT_TICK_DEADLINE_US = 1_000 +_DEFAULT_STATE_CACHE_TTL_S = 0.002 + _can_motor_control: Any | None _damiao: Any | None @@ -110,8 +115,8 @@ def __init__( address: str | Path | None = "can0", config_path: str | Path | None = None, use_mock_bus: bool = False, - tick_deadline_us: int = 1_000, - state_cache_ttl_s: float = 0.002, + tick_deadline_us: int = _DEFAULT_TICK_DEADLINE_US, + state_cache_ttl_s: float = _DEFAULT_STATE_CACHE_TTL_S, ) -> None: arm_spec.validate() if dof is not None and dof != arm_spec.dof: diff --git a/dimos/hardware/manipulators/openarm_rs/adapter.py b/dimos/hardware/manipulators/openarm_rs/adapter.py index 76225c05af..96e493ba61 100644 --- a/dimos/hardware/manipulators/openarm_rs/adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/adapter.py @@ -18,6 +18,8 @@ from typing import TYPE_CHECKING from dimos.hardware.manipulators.damiao.base_adapter import ( + _DEFAULT_STATE_CACHE_TTL_S, + _DEFAULT_TICK_DEADLINE_US, DamiaoArmAdapterBase, DamiaoBindingUnavailableError, ) @@ -71,8 +73,8 @@ def __init__( kp: list[float] | None = None, kd: list[float] | None = None, gravity_comp: bool = True, - tick_deadline_us: int = 1_000, - state_cache_ttl_s: float = 0.002, + tick_deadline_us: int = _DEFAULT_TICK_DEADLINE_US, + state_cache_ttl_s: float = _DEFAULT_STATE_CACHE_TTL_S, gravity_model_path: str | Path | None = None, gravity_torque_limits: list[float] | None = None, **_: object, From c26874e287167a89dac7ced181b9600e0dc7f640 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:30:58 +0300 Subject: [PATCH 022/110] refactor: single source of truth for default CAN address The 'can0' interface name was hard-coded three times: the base constructor default, the base None-coalesce, and the openarm_rs subclass default. Collapse them onto one _DEFAULT_ADDRESS constant so the literal lives in exactly one place. The None-coalesce stays to handle a config that leaves address unset (the factory then passes address=None). --- dimos/hardware/manipulators/damiao/base_adapter.py | 6 ++++-- dimos/hardware/manipulators/openarm_rs/adapter.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index 6a58bb7e4f..c3730db5be 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -31,6 +31,8 @@ # subclasses can't drift apart. _DEFAULT_TICK_DEADLINE_US = 1_000 _DEFAULT_STATE_CACHE_TTL_S = 0.002 +# Conventional first SocketCAN interface; used when no address is configured. +_DEFAULT_ADDRESS = "can0" _can_motor_control: Any | None _damiao: Any | None @@ -112,7 +114,7 @@ def __init__( gravity_model_path: str | Path | None = None, gravity_torque_limits: list[float] | tuple[float, ...] | None = None, supported_control_modes: tuple[ControlMode, ...] | None = None, - address: str | Path | None = "can0", + address: str | Path | None = _DEFAULT_ADDRESS, config_path: str | Path | None = None, use_mock_bus: bool = False, tick_deadline_us: int = _DEFAULT_TICK_DEADLINE_US, @@ -159,7 +161,7 @@ def __init__( self._last_positions: list[float] | None = None self._pin_model = None self._pin_data: object | None = None - self._address = str(address) if address is not None else "can0" + self._address = str(address) if address is not None else _DEFAULT_ADDRESS self._config_path = str(config_path) if config_path is not None else None self._arm_name = arm_spec.arm_name self._bus_name = arm_spec.bus_name diff --git a/dimos/hardware/manipulators/openarm_rs/adapter.py b/dimos/hardware/manipulators/openarm_rs/adapter.py index 96e493ba61..75c99fb80c 100644 --- a/dimos/hardware/manipulators/openarm_rs/adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/adapter.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING from dimos.hardware.manipulators.damiao.base_adapter import ( + _DEFAULT_ADDRESS, _DEFAULT_STATE_CACHE_TTL_S, _DEFAULT_TICK_DEADLINE_US, DamiaoArmAdapterBase, @@ -55,7 +56,7 @@ class OpenArmRSAdapter(DamiaoArmAdapterBase): def __init__( self, - address: str | Path | None = "can0", + address: str | Path | None = _DEFAULT_ADDRESS, dof: int = 7, *, hardware_id: str = "arm", From f98dbc383d893a43f3d05316bf3d0856a202c76c Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:32:30 +0300 Subject: [PATCH 023/110] perf: vectorize damiao state and command paths - refresh_state: build the position/velocity/torque lists with ndarray.tolist() instead of per-element float() loops. - write_mit_commands: assemble the MIT matrix with np.column_stack instead of a Python comprehension over zipped rows; this removes the now-redundant _mit_command_rows helper (validation moves to a direct _validate_command_lengths call). - read_state: look the control-mode index up in a precomputed _CONTROL_MODE_INDEX map instead of rebuilding list(ControlMode) every call. --- .../manipulators/damiao/base_adapter.py | 29 ++++++------------- .../manipulators/damiao/test_base_adapter.py | 2 +- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index c3730db5be..7eef27af45 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -33,6 +33,8 @@ _DEFAULT_STATE_CACHE_TTL_S = 0.002 # Conventional first SocketCAN interface; used when no address is configured. _DEFAULT_ADDRESS = "can0" +# Position of each control mode in declaration order, reported by read_state(). +_CONTROL_MODE_INDEX = {mode: index for index, mode in enumerate(ControlMode)} _can_motor_control: Any | None _damiao: Any | None @@ -186,18 +188,6 @@ def _validate_command_lengths(self, **commands: list[float]) -> None: def _zero_vector(self) -> list[float]: return [0.0] * self._dof - def _mit_command_rows( - self, - *, - q: list[float], - dq: list[float], - kp: list[float], - kd: list[float], - tau: list[float], - ) -> list[tuple[float, float, float, float, float]]: - self._validate_command_lengths(q=q, dq=dq, kp=kp, kd=kd, tau=tau) - return list(zip(q, dq, kp, kd, tau, strict=True)) - def get_info(self) -> ManipulatorInfo: return ManipulatorInfo( vendor=self._arm_spec.vendor, @@ -334,9 +324,9 @@ def refresh_state(self, *, force: bool = False) -> tuple[list[float], list[float self._arm.refresh() self._robot.tick(self._tick_deadline_us) state = ( - [float(value) for value in self._arm.positions().astype(np.float64)], - [float(value) for value in self._arm.velocities().astype(np.float64)], - [float(value) for value in self._arm.torques().astype(np.float64)], + self._arm.positions().astype(np.float64).tolist(), + self._arm.velocities().astype(np.float64).tolist(), + self._arm.torques().astype(np.float64).tolist(), ) if any(len(values) != self._dof for values in state): raise RuntimeError("can_motor_control state length does not match configured DOF") @@ -357,7 +347,7 @@ def read_joint_efforts(self) -> list[float]: def read_state(self) -> dict[str, int]: return { "state": 1 if self._enabled else 0, - "mode": list(ControlMode).index(self._control_mode), + "mode": _CONTROL_MODE_INDEX[self._control_mode], } def read_error(self) -> tuple[int, str]: @@ -431,10 +421,9 @@ def write_mit_commands( ) -> bool: if self._arm is None or self._robot is None or not self._enabled: return False - rows = self._mit_command_rows(q=q, dq=dq, kp=kp, kd=kd, tau=tau) - self._arm.mit_control( - np.array([(row[2], row[3], row[0], row[1], row[4]) for row in rows], dtype=np.float64) - ) + self._validate_command_lengths(q=q, dq=dq, kp=kp, kd=kd, tau=tau) + # MIT command columns are (kp, kd, q, dq, tau) per joint. + self._arm.mit_control(np.column_stack([kp, kd, q, dq, tau]).astype(np.float64)) self._robot.tick(self._tick_deadline_us) self._state_cache = None self._last_positions = list(q) diff --git a/dimos/hardware/manipulators/damiao/test_base_adapter.py b/dimos/hardware/manipulators/damiao/test_base_adapter.py index 82357f94c4..400da6f7c5 100644 --- a/dimos/hardware/manipulators/damiao/test_base_adapter.py +++ b/dimos/hardware/manipulators/damiao/test_base_adapter.py @@ -101,7 +101,7 @@ def test_base_adapter_info_limits_and_modes() -> None: def test_base_adapter_validates_command_lengths() -> None: adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec()) with pytest.raises(ValueError, match="q length 1 does not match dof 2"): - adapter._mit_command_rows( + adapter._validate_command_lengths( q=[0.0], dq=[0.0, 0.0], kp=[0.0, 0.0], From 227e84104149a626688f13b64b9e2ab9b76ef0c9 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:33:47 +0300 Subject: [PATCH 024/110] fix: use structured logging with tracebacks in damiao adapter The connect/disconnect/write_enable/write_clear_errors/write_stop paths stuffed context into f-string log lines and dropped the traceback on real failures. Switch to structlog key/value fields and use logger.exception (or warning with exc_info) so the traceback survives instead of being downgraded to an f-string error message. --- .../manipulators/damiao/base_adapter.py | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index 7eef27af45..d2c2c27afb 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -255,9 +255,12 @@ def connect(self) -> bool: self.refresh_state(force=True) except self._binding_error_type: raise - except Exception as exc: - logger.error( - f"{type(self).__name__} {self._hardware_id}@{self._address} connect failed: {exc}" + except Exception: + logger.exception( + "damiao adapter connect failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + address=self._address, ) self._robot = None self._arm = None @@ -298,9 +301,12 @@ def disconnect(self) -> None: if self._robot is not None: try: self._robot.disable() - except Exception as exc: + except Exception: logger.warning( - f"{type(self).__name__} {self._hardware_id} disable on disconnect failed: {exc}" + "damiao adapter disable on disconnect failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + exc_info=True, ) self._enabled = False self._connected = False @@ -449,8 +455,13 @@ def write_stop(self) -> bool: ) try: self._robot.disable() - except Exception as exc: - logger.warning(f"{type(self).__name__} {self._hardware_id} stop disable failed: {exc}") + except Exception: + logger.warning( + "damiao adapter stop disable failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + exc_info=True, + ) return False self._enabled = False return True @@ -460,14 +471,23 @@ def write_enable(self, enable: bool) -> bool: return False try: self._robot.enable() if enable else self._robot.disable() - except Exception as exc: - logger.error(f"{type(self).__name__} {self._hardware_id} enable={enable} failed: {exc}") + except Exception: + logger.exception( + "damiao adapter enable failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + enable=enable, + ) return False self._enabled = enable if enable: positions = self.read_joint_positions() if not self.write_joint_positions(positions): - logger.error(f"{type(self).__name__} {self._hardware_id} startup hold failed") + logger.error( + "damiao adapter startup hold failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + ) return False return True @@ -477,13 +497,21 @@ def write_clear_errors(self) -> bool: try: self._robot.disable() self._robot.enable() - except Exception as exc: - logger.error(f"{type(self).__name__} {self._hardware_id} clear errors failed: {exc}") + except Exception: + logger.exception( + "damiao adapter clear errors failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + ) return False self._enabled = True positions = self.read_joint_positions() if not self.write_joint_positions(positions): - logger.error(f"{type(self).__name__} {self._hardware_id} clear-error hold failed") + logger.error( + "damiao adapter clear-error hold failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + ) return False return True From a88758f7bb756cc87771c876a1b5ead0e977f492 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:34:10 +0300 Subject: [PATCH 025/110] fix: log dropped gravity feed-forward in write_joint_positions When the pre-command state read fails, the adapter substitutes zero feed-forward torque. Log a warning (with traceback) so the dropped gravity term is visible instead of being silently swallowed. --- dimos/hardware/manipulators/damiao/base_adapter.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index d2c2c27afb..a73d6c1c55 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -379,6 +379,14 @@ def write_joint_positions(self, positions: list[float], velocity: float = 1.0) - try: tau = self.compute_gravity_torques(self.read_joint_positions()) except RuntimeError: + # State read failed: fall back to zero feed-forward torque, but + # surface it so the dropped gravity term isn't silent. + logger.warning( + "damiao adapter dropping gravity feed-forward; state read failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + exc_info=True, + ) tau = self._zero_vector() else: tau = self._zero_vector() From 36940ea77be2ef056a1e41494464d238d01c417c Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:34:46 +0300 Subject: [PATCH 026/110] fix: narrow except in write_gravity_compensation The try wrapped both refresh_state and compute_gravity_torques under a broad except Exception, masking a real gravity-model error as 'invalid state'. Scope the try to refresh_state (the call that raises RuntimeError on a bad read) and let a genuine compute error propagate. Also switch the log line to structured fields with a traceback. --- dimos/hardware/manipulators/damiao/base_adapter.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index a73d6c1c55..ecd3ad823c 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -421,12 +421,15 @@ def write_joint_torques(self, efforts: list[float]) -> bool: def write_gravity_compensation(self, damping: float | list[float] = 0.0) -> bool: try: q, dq, _ = self.refresh_state(force=True) - tau = self.compute_gravity_torques(q) - except Exception as exc: + except RuntimeError: logger.warning( - f"Skipping {type(self).__name__} gravity compensation due to invalid state: {exc}" + "skipping damiao gravity compensation; state read failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + exc_info=True, ) return False + tau = self.compute_gravity_torques(q) kd = [float(damping)] * self._dof if isinstance(damping, int | float) else list(damping) return self.write_mit_commands(q=q, dq=dq, kp=self._zero_vector(), kd=kd, tau=tau) From e22e724e797705571ce63d260cab158034267f06 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:35:30 +0300 Subject: [PATCH 027/110] fix: reject unknown kwargs in OpenArmRSAdapter The **_: object catch-all silently dropped misspelled adapter kwargs. The registry factory only passes declared parameters (dof, address, hardware_id plus the blueprint adapter_kwargs), so drop the catch-all to turn config typos into a clear TypeError. --- dimos/hardware/manipulators/openarm_rs/adapter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dimos/hardware/manipulators/openarm_rs/adapter.py b/dimos/hardware/manipulators/openarm_rs/adapter.py index 75c99fb80c..9a70c9c7dc 100644 --- a/dimos/hardware/manipulators/openarm_rs/adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/adapter.py @@ -78,7 +78,6 @@ def __init__( state_cache_ttl_s: float = _DEFAULT_STATE_CACHE_TTL_S, gravity_model_path: str | Path | None = None, gravity_torque_limits: list[float] | None = None, - **_: object, ) -> None: if dof != len(self._DEFAULT_OPENARM_MOTORS): raise ValueError(f"OpenArmRSAdapter only supports 7 DOF (got {dof})") From 1b12e2d5cea9c7a006934a8c1d1a860cf7eb3327 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 9 Jun 2026 01:35:54 +0300 Subject: [PATCH 028/110] docs: point to kp/kd source constants instead of restating them The OpenArm integration guide inlined the kp/kd preset numbers, which drift from the adapter code. Replace them with a pointer to OpenArmRSAdapter._DEFAULT_KP/_DEFAULT_KD (and the openarm adapter's own constants). --- docs/capabilities/manipulation/openarm_integration.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/capabilities/manipulation/openarm_integration.md b/docs/capabilities/manipulation/openarm_integration.md index d75f7ce0a0..8d1f676538 100644 --- a/docs/capabilities/manipulation/openarm_integration.md +++ b/docs/capabilities/manipulation/openarm_integration.md @@ -258,12 +258,7 @@ No other code changes are needed. The `coordinator-openarm-rs` and `openarm-rs-p ### Gain tuning (MIT kp/kd) -Defaults live in the adapter implementations. The binding-backed `openarm_rs` path uses the upstream OpenArm ROS2 hardware presets from `openarm_hardware/openarm_simple_hardware.hpp`; the in-tree `openarm` adapter keeps its existing gains. OpenArm RS/OpenArm ROS2 presets are: - -```python -_DEFAULT_KP = [70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0] -_DEFAULT_KD = [2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5] -``` +Defaults live in the adapter implementations — pointing here instead of restating the numbers so docs can't drift from code. The binding-backed `openarm_rs` path uses the upstream OpenArm ROS2 hardware presets from `openarm_hardware/openarm_simple_hardware.hpp`; see `OpenArmRSAdapter._DEFAULT_KP` / `_DEFAULT_KD` in [openarm_rs/adapter.py](/dimos/hardware/manipulators/openarm_rs/adapter.py). The in-tree `openarm` adapter keeps its own `_DEFAULT_KP` / `_DEFAULT_KD` in [openarm/adapter.py](/dimos/hardware/manipulators/openarm/adapter.py). Guidelines: - `kp ∈ [0, 500]` in MIT mode. Higher kp = stiffer position tracking; too high → oscillation. From 76158b261a9a4c3f0509bc7a218db7cfe1010e44 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 8 Jun 2026 16:20:39 -0700 Subject: [PATCH 029/110] chore: revert change to doc folder --- docs/coding-agents/index.md | 1 - docs/development/openspec.md | 102 ----------------------------------- docs/docs.json | 1 - 3 files changed, 104 deletions(-) delete mode 100644 docs/development/openspec.md diff --git a/docs/coding-agents/index.md b/docs/coding-agents/index.md index 5ac7c854a7..ff778ac5cf 100644 --- a/docs/coding-agents/index.md +++ b/docs/coding-agents/index.md @@ -3,7 +3,6 @@ ├── worktrees.md (creating provisioned worktrees with `bin/worktree`) ├── style.md (code style guidelines for dimos) ├── testing.md (docs about writing tests) -├── ../development/openspec.md (OpenSpec behavior-spec workflow) ├── docs (these are docs about writing docs) │   ├── codeblocks.md │   ├── doclinks.md diff --git a/docs/development/openspec.md b/docs/development/openspec.md deleted file mode 100644 index 280eb0f57e..0000000000 --- a/docs/development/openspec.md +++ /dev/null @@ -1,102 +0,0 @@ -# OpenSpec Workflow - -DimOS uses OpenSpec as the checked-in planning layer for behavior changes. OpenSpec artifacts live under `openspec/` and should describe what the system is supposed to do, why it is changing, and how contributors or agents should validate the work. - -## Terminology - -Keep these two meanings separate: - -- **OpenSpec capability spec**: Markdown requirements under `openspec/specs//spec.md`. These describe observable behavior and acceptance scenarios. -- **DimOS Spec**: Python Protocol/RPC contracts in files like `dimos/navigation/navigation_spec.py` or `dimos/manipulation/control/arm_driver_spec.py`. These describe module interfaces for code wiring. - -Use "OpenSpec capability spec" in prose when there is any chance of confusion. - -## Schema - -The project uses the `dimos-capability` schema configured in `openspec/config.yaml`. - -The artifact flow is: - -```text -proposal - ├── specs - ├── design - └── docs - └── tasks -``` - -| Artifact | Purpose | -|---|---| -| `proposal.md` | Intent, scope, affected DimOS surfaces, and capability impact. | -| `specs//spec.md` | Behavior-first requirements and scenarios. | -| `design.md` | Module, stream, blueprint, skill/MCP, safety, and rollout decisions. | -| `docs.md` | Documentation impact and doc validation plan. | -| `tasks.md` | Implementation, docs, verification, and manual QA checklist. | - -## When to create a change - -Create an OpenSpec change when work changes observable behavior, public CLI/API/MCP behavior, robot behavior, hardware/simulation/replay workflows, docs that users rely on, or cross-module architecture. - -Do not create a change for a purely mechanical refactor, typo fix, or internal cleanup unless it changes behavior or needs cross-session planning context. - -## Writing specs - -OpenSpec capability specs are behavior contracts, not implementation plans. - -Good spec content: - -- User- or developer-visible behavior. -- Public CLI/API/MCP tool behavior. -- Stream or message behavior that downstream modules rely on. -- Robot safety constraints and hardware/simulation/replay expectations. -- Scenarios that can be tested or manually verified. - -Avoid in specs: - -- Private class/function names. -- Generated-file mechanics. -- Library choices and wiring details. -- Step-by-step implementation tasks. - -Put those details in `design.md` or `tasks.md`. - -## Capability names - -Prefer behavior-domain names over code names. Useful starting points: - -- `module-system` -- `blueprint-composition` -- `cli-lifecycle` -- `agent-skills-mcp` -- `configuration` -- `navigation-stack` -- `manipulation-stack` -- `hardware-adapters` -- `simulation-replay` -- `documentation-system` - -Add specs progressively as changes need them. Do not try to backfill the whole project at once. - -## Validation - -Use OpenSpec validation before implementation and before archiving: - -```bash skip -openspec schema validate dimos-capability -openspec validate -openspec templates --json -``` - -For documentation changes, also run the relevant doc checks from [Writing Docs](/docs/development/writing_docs.md): - -```bash skip -md-babel-py run -``` - -When a change touches blueprint names, module-level blueprint variables, or module registry inputs, run: - -```bash skip -pytest dimos/robot/test_all_blueprints_generation.py -``` - -Then run focused tests for the changed code and manually QA through the actual surface: CLI command, MCP tool, HTTP API, simulation/replay blueprint, hardware procedure, or library driver. diff --git a/docs/docs.json b/docs/docs.json index f0064c9ab9..58da2ff6a1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,7 +144,6 @@ "group": "Development", "pages": [ "development/conventions", - "development/openspec", "development/testing", "development/docker", "development/grid_testing", From a9e731a0ca5faeeff04e7389202ef06ec9d9a8cf Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 8 Jun 2026 16:29:28 -0700 Subject: [PATCH 030/110] test: focus manipulator tests on behavior --- .../manipulators/damiao/test_base_adapter.py | 24 +- .../manipulators/openarm/test_adapter.py | 80 ++-- .../manipulators/openarm_rs/test_adapter.py | 421 +----------------- .../.openspec.yaml | 2 + .../design.md | 75 ++++ .../docs.md | 22 + .../proposal.md | 37 ++ .../behavior-focused-test-guidelines/spec.md | 28 ++ .../dm-motor-manipulator-adapters/spec.md | 30 ++ .../manipulator-adapter-discovery/spec.md | 45 ++ .../openarm-rs-adapter-selection/spec.md | 34 ++ .../tasks.md | 20 + openspec/schemas/dimos-capability/schema.yaml | 3 + .../behavior-focused-test-guidelines/spec.md | 32 ++ .../dm-motor-manipulator-adapters/spec.md | 8 +- .../manipulator-adapter-discovery/spec.md | 10 +- .../openarm-rs-adapter-selection/spec.md | 10 +- 17 files changed, 409 insertions(+), 472 deletions(-) create mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/design.md create mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/docs.md create mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/proposal.md create mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/behavior-focused-test-guidelines/spec.md create mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/dm-motor-manipulator-adapters/spec.md create mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/manipulator-adapter-discovery/spec.md create mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/openarm-rs-adapter-selection/spec.md create mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/tasks.md create mode 100644 openspec/specs/behavior-focused-test-guidelines/spec.md diff --git a/dimos/hardware/manipulators/damiao/test_base_adapter.py b/dimos/hardware/manipulators/damiao/test_base_adapter.py index 400da6f7c5..516e4ec93e 100644 --- a/dimos/hardware/manipulators/damiao/test_base_adapter.py +++ b/dimos/hardware/manipulators/damiao/test_base_adapter.py @@ -39,12 +39,11 @@ def _arm_spec() -> DamiaoArmSpec: ) -def test_arm_spec_preserves_joint_order_and_metadata() -> None: +def test_arm_spec_exposes_joint_order_for_backend_commands() -> None: spec = _arm_spec() - assert spec.dof == 2 + assert spec.joint_names == ("j1", "j2") assert [motor.send_id for motor in spec.motors] == [0x01, 0x02] - assert [motor.effective_recv_id for motor in spec.motors] == [0x11, 0x12] def test_arm_spec_rejects_duplicate_ids() -> None: @@ -83,28 +82,13 @@ def test_arm_spec_rejects_length_mismatch() -> None: ).validate() -def test_base_adapter_info_limits_and_modes() -> None: +def test_base_adapter_reports_limits_and_accepts_supported_mode() -> None: adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec()) - info = adapter.get_info() - assert info.vendor == "Damiao" - assert info.model == "TestArm" + assert adapter.get_dof() == 2 limits = adapter.get_limits() assert limits.position_lower == [-1.0, -2.0] assert limits.position_upper == [1.0, 2.0] - assert limits.velocity_max == [3.0, 4.0] assert adapter.set_control_mode(ControlMode.TORQUE) is True assert adapter.get_control_mode() == ControlMode.TORQUE assert adapter.set_control_mode(ControlMode.VELOCITY) is False - - -def test_base_adapter_validates_command_lengths() -> None: - adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec()) - with pytest.raises(ValueError, match="q length 1 does not match dof 2"): - adapter._validate_command_lengths( - q=[0.0], - dq=[0.0, 0.0], - kp=[0.0, 0.0], - kd=[0.0, 0.0], - tau=[0.0, 0.0], - ) diff --git a/dimos/hardware/manipulators/openarm/test_adapter.py b/dimos/hardware/manipulators/openarm/test_adapter.py index 66eb87cb69..fd0ed75065 100644 --- a/dimos/hardware/manipulators/openarm/test_adapter.py +++ b/dimos/hardware/manipulators/openarm/test_adapter.py @@ -15,21 +15,21 @@ from __future__ import annotations import time -from unittest.mock import MagicMock import pytest from dimos.hardware.manipulators.openarm.adapter import OpenArmAdapter, register +from dimos.hardware.manipulators.registry import AdapterRegistry from dimos.hardware.manipulators.spec import ControlMode, ManipulatorAdapter class FakeState: def __init__(self, index: int) -> None: - self.q = 0.1 * index - self.dq = 0.2 * index - self.tau = 0.3 * index - self.t_rotor = 30 + index - self.timestamp = time.monotonic() + self.q: float = 0.1 * index + self.dq: float = 0.2 * index + self.tau: float = 0.3 * index + self.t_rotor: float = 30 + index + self.timestamp: float = time.monotonic() class FakeOpenArmBus: @@ -37,16 +37,16 @@ class FakeOpenArmBus: def __init__(self, channel: str, motors: list[object], *, fd: bool, interface: str) -> None: FakeOpenArmBus.last = self - self.channel = channel - self.motors = motors - self.fd = fd - self.interface = interface - self.opened = False - self.closed = False - self.disabled_count = 0 + self.channel: str = channel + self.motors: list[object] = motors + self.fd: bool = fd + self.interface: str = interface + self.opened: bool = False + self.closed: bool = False + self.disabled_count: int = 0 self.ctrl_mode_ids: list[int] = [] self.mit_commands: list[list[tuple[float, float, float, float, float]]] = [] - self.states = [FakeState(index) for index in range(len(motors))] + self.states: list[FakeState] = [FakeState(index) for index in range(len(motors))] def open(self) -> None: self.opened = True @@ -54,7 +54,7 @@ def open(self) -> None: def close(self) -> None: self.closed = True - def write_ctrl_mode(self, send_id: int, mode: int) -> None: + def write_ctrl_mode(self, send_id: int, _mode: int) -> None: self.ctrl_mode_ids.append(send_id) def get_states(self) -> list[FakeState]: @@ -75,30 +75,32 @@ def test_implements_manipulator_adapter() -> None: def test_register_preserves_openarm_key() -> None: - registry = MagicMock() + registry = AdapterRegistry() register(registry) - registry.register.assert_called_once_with("openarm", OpenArmAdapter) + adapter = registry.create("openarm", gravity_comp=False) + assert isinstance(adapter, OpenArmAdapter) def test_constructor_validates_dof_side_and_gain_lengths() -> None: with pytest.raises(ValueError, match="only supports 7 DOF"): - OpenArmAdapter(dof=6, gravity_comp=False) + _ = OpenArmAdapter(dof=6, gravity_comp=False) with pytest.raises(ValueError, match="side must be 'left' or 'right'"): - OpenArmAdapter(side="middle", gravity_comp=False) + _ = OpenArmAdapter(side="middle", gravity_comp=False) with pytest.raises(ValueError, match="kp/kd must be length 7"): - OpenArmAdapter(kp=[1.0], gravity_comp=False) + _ = OpenArmAdapter(kp=[1.0], gravity_comp=False) with pytest.raises(ValueError, match="kp/kd must be length 7"): - OpenArmAdapter(kd=[1.0], gravity_comp=False) + _ = OpenArmAdapter(kd=[1.0], gravity_comp=False) -def test_info_limits_and_modes_match_openarm_sides() -> None: +def test_side_selects_openarm_identity_limits_and_modes() -> None: left = OpenArmAdapter(side="left", gravity_comp=False) right = OpenArmAdapter(side="right", gravity_comp=False) + assert left.get_info().vendor == "Enactic" assert left.get_info().model == "OpenArm v10 (left)" assert right.get_info().model == "OpenArm v10 (right)" - assert left.get_limits().position_lower[:2] == pytest.approx([-3.45, -3.30]) - assert right.get_limits().position_lower[:2] == pytest.approx([-1.35, -0.15]) + assert left.get_limits().position_lower[:2] == [-3.45, -3.30] + assert right.get_limits().position_lower[:2] == [-1.35, -0.15] assert left.set_control_mode(ControlMode.VELOCITY) is True assert left.get_control_mode() == ControlMode.VELOCITY assert left.set_control_mode(ControlMode.CARTESIAN) is False @@ -106,19 +108,11 @@ def test_info_limits_and_modes_match_openarm_sides() -> None: def test_disconnected_surface_returns_safe_defaults() -> None: adapter = OpenArmAdapter(gravity_comp=False) + assert adapter.is_connected() is False - assert adapter.read_state() == {"state": 0, "mode": 0} - assert adapter.read_error() == (0, "") assert adapter.write_joint_positions([0.0] * 7) is False - assert adapter.write_joint_velocities([0.0] * 7) is False assert adapter.write_stop() is False assert adapter.write_enable(True) is False - assert adapter.write_clear_errors() is False - assert adapter.read_cartesian_position() is None - assert adapter.write_cartesian_position({}) is False - assert adapter.read_gripper_position() is None - assert adapter.write_gripper_position(0.0) is False - assert adapter.read_force_torque() is None def test_lifecycle_state_commands_and_disconnect(monkeypatch: pytest.MonkeyPatch) -> None: @@ -132,21 +126,23 @@ def test_lifecycle_state_commands_and_disconnect(monkeypatch: pytest.MonkeyPatch bus = FakeOpenArmBus.last assert bus is not None assert bus.opened is True - assert bus.ctrl_mode_ids == [1, 2, 3, 4, 5, 6, 7] assert adapter.write_enable(True) is True assert adapter.read_enabled() is True - assert adapter.read_joint_positions() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) - assert adapter.read_joint_velocities() == pytest.approx([0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2]) - assert adapter.read_joint_efforts() == pytest.approx([0.0, 0.3, 0.6, 0.9, 1.2, 1.5, 1.8]) - assert adapter.read_state()["t_rotor_max"] == 36 + assert [round(position, 1) for position in adapter.read_joint_positions()] == [ + 0.0, + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + ] assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True - assert bus.mit_commands[-1][0] == pytest.approx((0.1, 0.0, 50.0, 1.5, 0.0)) - assert adapter.write_joint_velocities([0.2] * 7) is True - assert bus.mit_commands[-1][0] == pytest.approx((0.1, 0.2, 0.0, 1.5, 0.0)) + assert bus.mit_commands[-1][0] == (0.1, 0.0, 50.0, 1.5, 0.0) assert adapter.write_stop() is True - assert bus.mit_commands[-1][0] == pytest.approx((0.0, 0.0, 100.0, 1.5, 0.0)) + assert bus.mit_commands[-1][0] == (0.0, 0.0, 100.0, 1.5, 0.0) adapter.disconnect() assert adapter.read_enabled() is False diff --git a/dimos/hardware/manipulators/openarm_rs/test_adapter.py b/dimos/hardware/manipulators/openarm_rs/test_adapter.py index 7e3042522c..16738779b6 100644 --- a/dimos/hardware/manipulators/openarm_rs/test_adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/test_adapter.py @@ -14,436 +14,47 @@ from __future__ import annotations -from enum import IntEnum -import sys -from types import ModuleType -from unittest.mock import MagicMock - -import numpy as np import pytest -import dimos.hardware.manipulators.damiao.base_adapter as damiao_base_adapter -from dimos.hardware.manipulators.openarm_rs.adapter import ( - OpenArmRSAdapter, - OpenArmRSBindingUnavailableError, - register, -) -from dimos.hardware.manipulators.spec import ControlMode, ManipulatorAdapter - - -class FakeMotorType(IntEnum): - DM4310 = 1 - DM4340 = 3 - DM8006 = 6 - - -class FakeDamiao(ModuleType): - MotorType = FakeMotorType - - class DamiaoCodec: - pass - - -class FakeMotorSpec: - def __init__(self, name: str, type: FakeMotorType, send_id: int, recv_id: int) -> None: - if not isinstance(type, FakeMotorType): - raise TypeError("type must be FakeMotorType") - self.name = name - self.type = type - self.send_id = send_id - self.recv_id = recv_id - - -class FakeMotor: - fault: int | None = None - - -class FakeArm: - def __init__(self, dof: int) -> None: - self.dof = dof - self.positions_value = np.array([0.1 * i for i in range(dof)], dtype=np.float64) - self.velocities_value = np.array([0.2 * i for i in range(dof)], dtype=np.float64) - self.torques_value = np.array([0.3 * i for i in range(dof)], dtype=np.float64) - self.mit_commands: list[np.ndarray] = [] - self.position_commands: list[np.ndarray] = [] - self.velocity_commands: list[np.ndarray] = [] - self.refresh_count = 0 - self.motors = {f"joint{i + 1}": FakeMotor() for i in range(dof)} - - def __len__(self) -> int: - return self.dof - - def __getitem__(self, name: str) -> FakeMotor: - return self.motors[name] - - def positions(self) -> np.ndarray: - return self.positions_value - - def velocities(self) -> np.ndarray: - return self.velocities_value - - def torques(self) -> np.ndarray: - return self.torques_value - - def refresh(self) -> None: - self.refresh_count += 1 - - def mit_control(self, cmds: np.ndarray) -> None: - self.mit_commands.append(cmds.copy()) - - def pos_vel_control(self, cmds: np.ndarray) -> None: - self.position_commands.append(cmds.copy()) - - def vel_control(self, cmds: np.ndarray) -> None: - self.velocity_commands.append(cmds.copy()) - - -class FakeRobot: - last: FakeRobot | None = None - - def __init__(self, dof: int = 7, transport: object | None = None) -> None: - FakeRobot.last = self - self.arm = FakeArm(dof) - self.transport = transport - self.config_path: str | None = None - self.connected = False - self.enabled = False - self.tick_count = 0 - self.disabled_count = 0 - - @classmethod - def builder(cls) -> FakeRobotBuilder: - return FakeRobotBuilder() - - @classmethod - def from_config(cls, path: str) -> FakeRobot: - robot = cls() - robot.config_path = path - return robot - - def connect(self) -> None: - self.connected = True - - def enable(self) -> None: - self.enabled = True - - def disable(self) -> None: - self.enabled = False - self.disabled_count += 1 - - def tick(self, per_bus_deadline_us: int) -> None: - self.tick_count += 1 - - def __getitem__(self, name: str) -> FakeArm: - return self.arm - - -class FakeRobotBuilder: - def __init__(self) -> None: - self.motors: list[FakeMotorSpec] = [] - self.transport: object | None = None - - def add_bus(self, name: str, transport: object, codec: object) -> FakeRobotBuilder: - self.transport = transport - return self - - def add_arm(self, name: str, *, bus: str, motors: list[FakeMotorSpec]) -> FakeRobotBuilder: - self.motors = motors - return self - - def build(self) -> FakeRobot: - return FakeRobot(len(self.motors), transport=self.transport) - - -class FakeDMControl(ModuleType): - Robot = FakeRobot - MotorSpec = FakeMotorSpec - - class MockCanBus: - def __init__(self, name: str, fd: bool = False) -> None: - self.name = name - self.fd = fd - - @staticmethod - def new_fd(name: str) -> FakeDMControl.MockCanBus: - return FakeDMControl.MockCanBus(name, fd=True) - - class SocketCanBus: - def __init__(self, interface: str, fd: bool = False) -> None: - self.interface = interface - self.fd = fd - - -@pytest.fixture(autouse=True) -def fake_can_motor_control(monkeypatch: pytest.MonkeyPatch) -> None: - fake_dm = FakeDMControl("can_motor_control") - fake_damiao = FakeDamiao("can_motor_control.damiao") - monkeypatch.setattr(fake_dm, "damiao", fake_damiao, raising=False) - monkeypatch.setitem(sys.modules, "can_motor_control", fake_dm) - monkeypatch.setitem(sys.modules, "can_motor_control.damiao", fake_damiao) - monkeypatch.setattr(damiao_base_adapter, "_can_motor_control", fake_dm) - monkeypatch.setattr(damiao_base_adapter, "_damiao", fake_damiao) - monkeypatch.setattr(damiao_base_adapter, "_can_motor_control_import_error", None) - FakeRobot.last = None +from dimos.hardware.manipulators.openarm_rs.adapter import OpenArmRSAdapter, register +from dimos.hardware.manipulators.registry import AdapterRegistry +from dimos.hardware.manipulators.spec import ManipulatorAdapter def test_implements_manipulator_adapter() -> None: assert isinstance(OpenArmRSAdapter(use_mock_bus=True), ManipulatorAdapter) -def test_register() -> None: - registry = MagicMock() - register(registry) - registry.register.assert_called_once_with("openarm_rs", OpenArmRSAdapter) +def test_register_preserves_openarm_rs_key() -> None: + registry = AdapterRegistry() + register(registry) -def test_defaults_match_openarm_ros2_hardware_presets() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True) - assert adapter._kp == pytest.approx([70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0]) - assert adapter._kd == pytest.approx([2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5]) + adapter = registry.create("openarm_rs", use_mock_bus=True) + assert isinstance(adapter, OpenArmRSAdapter) def test_side_selects_openarm_joint_limits() -> None: left = OpenArmRSAdapter(side="left", use_mock_bus=True) right = OpenArmRSAdapter(side="right", use_mock_bus=True) - assert left.get_limits().position_lower == pytest.approx( - [-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50] - ) - assert left.get_limits().position_upper == pytest.approx( - [1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50] - ) - assert right.get_limits().position_lower == pytest.approx( - [-1.35, -0.15, -1.50, -0.01, -1.50, -0.75, -1.50] - ) - assert right.get_limits().position_upper == pytest.approx( - [3.45, 3.30, 1.50, 2.40, 1.50, 0.75, 1.50] - ) + assert left.get_limits().position_lower[:2] == [-3.45, -3.30] + assert right.get_limits().position_lower[:2] == [-1.35, -0.15] def test_invalid_side_is_rejected() -> None: with pytest.raises(ValueError, match="side must be 'left' or 'right'"): - OpenArmRSAdapter(side="middle", use_mock_bus=True) - - -def test_canfd_enabled_by_default_for_mock_and_socket_buses() -> None: - mock_adapter = OpenArmRSAdapter(use_mock_bus=True) - assert mock_adapter.connect() is True - mock_robot = FakeRobot.last - assert mock_robot is not None - assert mock_adapter._fd is True - assert isinstance(mock_robot.transport, FakeDMControl.MockCanBus) - assert mock_robot.transport.fd is True - - socket_adapter = OpenArmRSAdapter(use_mock_bus=False) - assert socket_adapter.connect() is True - socket_robot = FakeRobot.last - assert socket_robot is not None - assert socket_adapter._fd is True - assert isinstance(socket_robot.transport, FakeDMControl.SocketCanBus) - assert socket_robot.transport.fd is True - - -def test_canfd_flag_and_legacy_fd_override_can_disable_fd() -> None: - canfd_adapter = OpenArmRSAdapter(use_mock_bus=True, canfd=False) - assert canfd_adapter.connect() is True - canfd_robot = FakeRobot.last - assert canfd_robot is not None - assert canfd_adapter._fd is False - assert isinstance(canfd_robot.transport, FakeDMControl.MockCanBus) - assert canfd_robot.transport.fd is False - - fd_adapter = OpenArmRSAdapter(use_mock_bus=True, canfd=True, fd=False) - assert fd_adapter.connect() is True - fd_robot = FakeRobot.last - assert fd_robot is not None - assert fd_adapter._fd is False - assert isinstance(fd_robot.transport, FakeDMControl.MockCanBus) - assert fd_robot.transport.fd is False - - -def test_default_motor_specs_use_binding_motor_type_values() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True) - assert adapter.connect() is True - robot = FakeRobot.last - assert robot is not None - assert robot.arm.dof == 7 - - -def test_can_motor_control_binding_is_loaded_at_base_module_import() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True) - assert adapter.connect() is True - assert vars(damiao_base_adapter)["_can_motor_control"] is sys.modules["can_motor_control"] - assert vars(damiao_base_adapter)["_damiao"] is sys.modules["can_motor_control.damiao"] - - -def test_missing_binding_fails_only_when_selected(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delitem(sys.modules, "can_motor_control", raising=False) - monkeypatch.delitem(sys.modules, "can_motor_control.damiao", raising=False) - monkeypatch.setattr(damiao_base_adapter, "_can_motor_control", None) - monkeypatch.setattr(damiao_base_adapter, "_damiao", None) - monkeypatch.setattr( - damiao_base_adapter, - "_can_motor_control_import_error", - ImportError("can_motor_control"), - ) - adapter = OpenArmRSAdapter(use_mock_bus=True) - with pytest.raises(OpenArmRSBindingUnavailableError, match="openarm_rs.*can-motor-control"): - adapter.connect() - - -def test_lifecycle_read_write_disable() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True, gravity_comp=False) - assert adapter.connect() is True - assert adapter.write_enable(True) is True - assert adapter.read_enabled() is True - robot = FakeRobot.last - assert robot is not None - startup_cmd = robot.arm.mit_commands[-1] - assert startup_cmd[:, 2].tolist() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) - assert startup_cmd[:, 3].tolist() == pytest.approx([0.0] * 7) - assert startup_cmd[:, 4].tolist() == pytest.approx([0.0] * 7) - assert adapter.read_joint_positions() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) - assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True - assert robot.arm.position_commands == [] - assert robot.arm.velocity_commands == [] - cmd = robot.arm.mit_commands[-1] - assert cmd[:, 2].tolist() == pytest.approx([0.1] * 7) - assert cmd[:, 0].tolist() == pytest.approx([35.0, 35.0, 35.0, 30.0, 5.0, 5.0, 5.0]) - assert cmd[:, 1].tolist() == pytest.approx([2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5]) - assert cmd[:, 4].tolist() == pytest.approx([0.0] * 7) - adapter.disconnect() - assert robot.disabled_count >= 1 - - -def test_clear_errors_sends_current_position_hold() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True, gravity_comp=False) - assert adapter.connect() is True - robot = FakeRobot.last - assert robot is not None - - assert adapter.write_clear_errors() is True - - cmd = robot.arm.mit_commands[-1] - assert cmd[:, 2].tolist() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) - assert cmd[:, 3].tolist() == pytest.approx([0.0] * 7) - assert cmd[:, 4].tolist() == pytest.approx([0.0] * 7) - - -def test_state_reads_share_one_tick() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True, state_cache_ttl_s=10.0) - assert adapter.connect() is True - robot = FakeRobot.last - assert robot is not None - robot.arm.refresh_count = 0 - robot.tick_count = 0 - adapter._state_cache = None - adapter.read_joint_positions() - adapter.read_joint_velocities() - adapter.read_joint_efforts() - assert robot.tick_count == 1 - assert robot.arm.refresh_count == 1 - - -def test_uncached_state_read_queues_no_motion_refresh() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True, state_cache_ttl_s=0.0) - assert adapter.connect() is True - robot = FakeRobot.last - assert robot is not None - - robot.arm.refresh_count = 0 - robot.tick_count = 0 - assert adapter.read_joint_positions() == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) - - assert robot.arm.refresh_count == 1 - assert robot.tick_count == 1 - assert robot.arm.mit_commands == [] - - -def test_default_gains_match_openarm_ros2_presets() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True) - assert adapter.connect() is True - assert adapter.write_enable(True) is True - assert adapter.write_joint_positions([0.1] * 7) is True - robot = FakeRobot.last - assert robot is not None - cmd = robot.arm.mit_commands[-1] - assert cmd[:, 0].tolist() == pytest.approx([70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0]) - assert cmd[:, 1].tolist() == pytest.approx([2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5]) - - -def test_position_commands_use_in_place_gravity_compensation_by_default( - monkeypatch: pytest.MonkeyPatch, -) -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True, kp=[10.0] * 7, kd=[0.2] * 7) - assert adapter.connect() is True - assert adapter.write_enable(True) is True - monkeypatch.setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) - assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True - robot = FakeRobot.last - assert robot is not None - assert robot.arm.position_commands == [] - cmd = robot.arm.mit_commands[-1] - assert cmd[:, 2].tolist() == pytest.approx([0.1] * 7) - assert cmd[:, 3].tolist() == pytest.approx([0.0] * 7) - assert cmd[:, 0].tolist() == pytest.approx([5.0] * 7) - assert cmd[:, 1].tolist() == pytest.approx([0.2] * 7) - assert cmd[:, 4].tolist() == pytest.approx([1.0] * 7) - assert adapter.get_control_mode() == ControlMode.POSITION - - -def test_velocity_mode_and_commands_are_unsupported() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True) - assert adapter.connect() is True - assert adapter.write_enable(True) is True - assert adapter.set_control_mode(ControlMode.VELOCITY) is False - assert adapter.write_joint_velocities([0.2] * 7) is False - robot = FakeRobot.last - assert robot is not None - assert len(robot.arm.mit_commands) == 1 - assert robot.arm.velocity_commands == [] - assert adapter.get_control_mode() == ControlMode.POSITION - - -def test_gravity_comp_false_uses_mit_position_without_effort() -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True, gravity_comp=False, kp=[10.0] * 7, kd=[0.2] * 7) - assert adapter.connect() is True - assert adapter.write_enable(True) is True - assert adapter.write_joint_positions([0.1] * 7, velocity=0.5) is True - robot = FakeRobot.last - assert robot is not None - assert robot.arm.position_commands == [] - assert robot.arm.velocity_commands == [] - cmd = robot.arm.mit_commands[-1] - assert cmd[:, 2].tolist() == pytest.approx([0.1] * 7) - assert cmd[:, 0].tolist() == pytest.approx([5.0] * 7) - assert cmd[:, 1].tolist() == pytest.approx([0.2] * 7) - assert cmd[:, 4].tolist() == pytest.approx([0.0] * 7) - - -def test_gravity_compensation_command_shape(monkeypatch: pytest.MonkeyPatch) -> None: - adapter = OpenArmRSAdapter(use_mock_bus=True) - assert adapter.connect() is True - assert adapter.write_enable(True) is True - monkeypatch.setattr(adapter, "compute_gravity_torques", lambda q: [1.0] * 7) - assert adapter.write_gravity_compensation(damping=0.05) is True - robot = FakeRobot.last - assert robot is not None - cmd = robot.arm.mit_commands[-1] - assert cmd[:, 0].tolist() == pytest.approx([0.0] * 7) - assert cmd[:, 1].tolist() == pytest.approx([0.05] * 7) - assert cmd[:, 4].tolist() == pytest.approx([1.0] * 7) - assert adapter.get_control_mode() == ControlMode.TORQUE + _ = OpenArmRSAdapter(side="middle", use_mock_bus=True) def test_non_openarm_dof_is_rejected() -> None: with pytest.raises(ValueError, match="only supports 7 DOF"): - OpenArmRSAdapter(dof=2, use_mock_bus=True) + _ = OpenArmRSAdapter(dof=2, use_mock_bus=True) def test_custom_non_openarm_metadata_is_rejected() -> None: with pytest.raises(ValueError, match="does not accept custom motor_specs"): - OpenArmRSAdapter( + _ = OpenArmRSAdapter( use_mock_bus=True, motor_specs=[ {"name": "shoulder", "type": "DM4310", "send_id": 1, "recv_id": 17}, @@ -451,19 +62,19 @@ def test_custom_non_openarm_metadata_is_rejected() -> None: ) with pytest.raises(ValueError, match="fixed OpenArm limits"): - OpenArmRSAdapter( + _ = OpenArmRSAdapter( use_mock_bus=True, position_lower=[-0.5] * 7, ) -def test_openarm_rs_info_is_openarm_specific() -> None: +def test_openarm_rs_reports_openarm_specific_identity() -> None: adapter = OpenArmRSAdapter( use_mock_bus=True, kp=[3.0] * 7, kd=[0.3] * 7, ) + assert adapter.get_info().vendor == "Enactic" assert adapter.get_info().model == "OpenArm RS v10" assert adapter.get_dof() == 7 - assert [motor.name for motor in adapter._motor_specs] == [f"joint{i}" for i in range(1, 8)] diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/.openspec.yaml b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/.openspec.yaml new file mode 100644 index 0000000000..e18884737b --- /dev/null +++ b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-08 diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/design.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/design.md new file mode 100644 index 0000000000..644cc677bf --- /dev/null +++ b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/design.md @@ -0,0 +1,75 @@ +## Context + +The branch adds and changes manipulator adapter tests around Damiao/OpenArm/OpenArm RS adapters and lazy manipulator adapter discovery. Several tests currently over-assert details such as private adapter fields, full default gain tables, motor-spec internals, and command-matrix columns. These details may be relevant inside an implementation, but tests that assert all of them at construction time do not communicate the behavior being protected. + +The repository already has testing guidance in `docs/development/testing.md` and stricter coding-agent guidance in `docs/coding-agents/testing.md`. The current guidance covers fixtures, cleanup, imports, sleeps, prints, and deterministic assertions, but it does not explicitly warn against low-value object-shape tests. + +## Goals / Non-Goals + +**Goals:** + +- Refine manipulator tests so each test has a clear setup, execution step, and observable expected result. +- Preserve coverage for behavior that matters while excluding fake control-binding behavior from OpenArm RS unit tests. +- Remove or collapse tests that only assert implementation shape or every minor detail of constructed objects. +- Add coding-agent documentation that describes behavior-focused test structure and an over-assertion review checklist. + +**Non-Goals:** + +- Change runtime adapter behavior, hardware protocol behavior, CLI behavior, streams, blueprints, or public APIs. +- Add new manipulator features. +- Require real hardware, simulation, or replay QA for this docs/test-only refinement. +- Turn every existing repository test into the new style in this change. + +## DimOS Architecture + +This change is limited to tests and contributor/coding-agent documentation. + +- Manipulator adapter Protocol surface: OpenArm RS tests should exercise constructor-time public metadata and validation only, without mocking or driving `can_motor_control`. +- Manipulator adapter registry: tests should exercise `available()` and selected `create()` behavior without importing unselected hardware SDKs. +- Modules/streams/transports/blueprints/RPC: no production architecture changes. +- Skills/MCP/CLI: no changes. +- Generated registries: no expected regeneration. + +## Decisions + +1. **Test behavior through public surfaces rather than private fields.** + - Rationale: private fields and construction details are refactor-sensitive and often do not reveal which behavior matters. + - Alternative considered: retain broad object snapshots as regression tests. Rejected because they increase maintenance cost and hide the real behavioral contract. + +2. **Use fakes to observe effects at integration boundaries.** + - Rationale: adapter tests need to verify that calls reach backend abstractions without real hardware. Fake backends should expose small observations such as "last position target" or "transport fd mode" rather than requiring tests to inspect every internal matrix cell. + - Alternative considered: keep direct assertions on backend command matrix columns. Some targeted matrix assertions may remain when they are the smallest way to prove a safety behavior, but they should be named and scoped to that behavior. + +3. **Prune tests that cannot name the desired behavior.** + - Rationale: if a test name cannot state the behavior it protects, the test is likely asserting object shape rather than functionality. + - Alternative considered: rename all tests without deleting any. Rejected because renamed over-assertive tests still constrain implementation unnecessarily. + +4. **Document the mistake in coding-agent guidance.** + - Rationale: the issue was produced by agent-written tests, so the durable fix needs guidance where coding agents look before writing tests. + +## Safety / Simulation / Replay + +No runtime hardware behavior changes are intended. Test refinement must preserve safety-relevant behavioral checks, especially: + +- OpenArm/OpenArm RS commands that hold or stop hardware safely. +- Gravity-compensation behavior where the adapter intentionally sends torque/damping behavior instead of a stiff position command. +- Lazy registry behavior so partial installations remain safe and understandable without OpenArm RS tests simulating the control binding. + +Manual QA surface is the focused pytest suite for changed manipulator tests plus inspection of documentation rendering/link validity. Real hardware, MuJoCo, replay, and MCP surfaces are out of scope. + +## Risks / Trade-offs + +- **Risk: pruning too much coverage.** Mitigation: keep tests for named behavior contracts and error boundaries before removing detail assertions. +- **Risk: hiding safety-critical constants.** Mitigation: if a constant table is safety-critical, test the resulting command behavior and name the safety reason explicitly. +- **Risk: documentation becomes generic advice.** Mitigation: include concrete bad/good examples that mirror DimOS adapter tests. + +## Migration / Rollout + +- Update only test files and `docs/coding-agents/testing.md`. +- Run focused manipulator tests after implementation. +- Run documentation link validation if available for the changed docs. +- No data migration, generated registry update, dependency change, or deployment step is required. + +## Open Questions + +- Which existing over-assertive tests should be deleted outright versus collapsed into broader behavior tests will be decided during implementation by mapping each assertion to a named behavior. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/docs.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/docs.md new file mode 100644 index 0000000000..b2bd6cef97 --- /dev/null +++ b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/docs.md @@ -0,0 +1,22 @@ +## User-Facing Docs + +None. This change does not alter user-facing DimOS behavior, hardware setup, CLI commands, skills/MCP tools, or platform capability guides. + +## Contributor Docs + +No broad contributor-process update is required in `docs/development/`. The existing `docs/development/testing.md` already covers test location, pytest usage, fixtures, mocking, options, and markers. + +## Coding-Agent Docs + +Do not add this guidance to `docs/coding-agents/testing.md`. The durable location is the OpenSpec schema instructions that generate and apply implementation task lists, because the mistake occurs when agents plan and execute test tasks. + +Update `openspec/schemas/dimos-capability/schema.yaml` so future `tasks` and `apply` instructions tell agents to write behavior-focused tests: set up the test, execute functionality, and check the desired result. The prompt guidance should warn against tests that only construct objects and assert private fields, full metadata snapshots, default tables, or fake backend internals. + +## Doc Validation + +- `openspec validate improve-behavior-focused-test-guidelines` +- Manually inspect the OpenSpec instructions output for a future apply/tasks flow if prompt wording changes are significant. + +## No Docs Needed + +User-facing documentation is not needed because runtime behavior does not change. The guidance belongs in OpenSpec prompt instructions rather than general coding-agent docs. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/proposal.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/proposal.md new file mode 100644 index 0000000000..2b3bc10dc1 --- /dev/null +++ b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/proposal.md @@ -0,0 +1,37 @@ +## Why + +Recent manipulator adapter tests include low-value assertions that construct objects and verify many incidental details, such as private fields, full default tables, and backend command matrix internals. Those tests are hard to review because the desired behavior is unclear, and they make future refactors feel risky even when public behavior is unchanged. + +This change refines the new manipulator tests so they check functionality through the adapter/registry surfaces, and strengthens OpenSpec task/apply prompts so future generated test work follows setup, execute, and verify structure instead of over-asserting implementation shape. + +## What Changes + +- Refactor low-value manipulator adapter tests into behavior-focused tests with clear setup, action, and desired outcome. +- Remove or collapse tests that only assert object construction details, private attributes, default metadata snapshots, or every minor backend command field. +- Preserve tests for real behavioral contracts while keeping OpenArm RS unit tests limited to non-control-binding behavior. +- Add explicit OpenSpec prompt guidance for writing behavior-focused unit tests and avoiding over-assertive object-shape tests. +- No public API, CLI, stream, hardware protocol, or runtime behavior changes are intended. + +## Affected DimOS Surfaces + +- Modules/streams: none at runtime; tests exercise manipulator adapter and registry surfaces. +- Blueprints/CLI: none. +- Skills/MCP: none. +- Hardware/simulation/replay: no hardware behavior changes; OpenArm RS unit tests avoid fake `can_motor_control` hardware behavior. +- Docs/generated registries: `openspec/schemas/dimos-capability/schema.yaml`; no generated registries expected. + +## Capabilities + +### New Capabilities +- `behavior-focused-test-guidelines`: Contributor and coding-agent expectations for writing unit tests that verify observable behavior rather than incidental object shape. + +### Modified Capabilities +- `dm-motor-manipulator-adapters`: Clarify test coverage expectations for manipulator adapter behavior without changing adapter requirements. +- `manipulator-adapter-discovery`: Clarify test coverage expectations for lazy registry discovery without changing registry requirements. +- `openarm-rs-adapter-selection`: Clarify test coverage expectations for OpenArm RS adapter behavior without changing adapter selection requirements. + +## Impact + +Developers get a clearer, smaller test suite that fails when functionality regresses rather than when implementation details shift. OpenSpec-driven implementation agents get concrete guidance to avoid repeating the over-assertion mistake. + +Compatibility risk is low because the change is limited to tests and documentation. Test/QA scope is the focused manipulator test set plus a documentation review of the new behavior-focused testing guidance. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/behavior-focused-test-guidelines/spec.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/behavior-focused-test-guidelines/spec.md new file mode 100644 index 0000000000..21d21f4e80 --- /dev/null +++ b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/behavior-focused-test-guidelines/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: Behavior-focused unit test guidance + +DimOS SHALL provide coding-agent testing guidance that tells agents to write unit tests around observable behavior rather than incidental object shape. + +#### Scenario: Agent prepares to write a unit test +- **GIVEN** a coding agent is writing or reviewing a unit test +- **WHEN** it consults the DimOS coding-agent testing guidance +- **THEN** the guidance SHALL instruct it to structure the test as setup, execute functionality, and check the desired result +- **AND** the guidance SHALL discourage tests that only construct an object and assert many minor properties. + +#### Scenario: Agent reviews an over-assertive test +- **GIVEN** a proposed test asserts private fields, full metadata snapshots, or every backend command detail +- **WHEN** the agent applies the testing guidance +- **THEN** it SHALL either replace those assertions with a public behavior check or justify the smallest safety-critical assertion needed +- **AND** it SHALL prefer a test name that states the desired behavior being protected. + +### Requirement: Test-quality review checklist + +DimOS SHALL provide a concise checklist for coding agents to evaluate whether a unit test is behavior-focused. + +#### Scenario: Agent self-reviews a new test +- **GIVEN** a coding agent has written a new unit test +- **WHEN** it applies the checklist +- **THEN** the checklist SHALL ask what behavior would break if the test failed +- **AND** it SHALL ask whether the test executed functionality rather than only constructing an object +- **AND** it SHALL ask whether the assertions target public outcomes instead of private implementation details. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/dm-motor-manipulator-adapters/spec.md new file mode 100644 index 0000000000..f9b676074b --- /dev/null +++ b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/dm-motor-manipulator-adapters/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: Binding-backed adapter safety behavior + +DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. Tests for this behavior SHALL verify observable adapter outcomes and safety-relevant command effects rather than broad snapshots of private adapter construction state. + +#### Scenario: Coherent state reads +- **GIVEN** the OpenArm RS adapter is connected through the binding-backed path +- **WHEN** DimOS reads positions, velocities, and efforts during one coordinator cycle +- **THEN** the adapter SHALL provide a coherent state snapshot rather than independently loading the CAN bus for each read +- **AND** stale or malformed state SHALL be surfaced before unsafe commands are sent. + +#### Scenario: Gravity-compensation-only command +- **GIVEN** a user invokes a gravity-compensation-only validation path through the binding-backed adapter +- **WHEN** the adapter sends MIT commands for gravity compensation +- **THEN** the command SHALL use zero position stiffness so the arm remains manually movable +- **AND** the command SHALL use current measured state and configured OpenArm gravity metadata. + +#### Scenario: Missing binding remains selected-adapter scoped +- **GIVEN** the optional binding is not installed +- **WHEN** DimOS discovers or lists manipulator adapters without selecting OpenArm RS +- **THEN** discovery SHALL continue for adapters that do not require the binding +- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path +- **AND** registry listing MUST NOT import the OpenArm RS implementation only to discover its adapter key. + +#### Scenario: Safety tests remain behavior-focused +- **GIVEN** a test protects binding-backed adapter safety behavior +- **WHEN** the test verifies command output or state behavior +- **THEN** it SHALL name the safety behavior being protected +- **AND** it SHALL avoid asserting unrelated private fields or full default tables that are not required to prove that behavior. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/manipulator-adapter-discovery/spec.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/manipulator-adapter-discovery/spec.md new file mode 100644 index 0000000000..00756c8100 --- /dev/null +++ b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/manipulator-adapter-discovery/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: Metadata-only manipulator adapter listing + +DimOS SHALL list registered manipulator adapter keys without importing unselected adapter implementation modules. Tests for this requirement SHALL verify discovery behavior through registry listing and module-import observability rather than asserting incidental registry object internals. + +#### Scenario: Listing adapters in a partial installation +- **GIVEN** a DimOS environment where one or more optional manipulator hardware SDKs are not installed +- **WHEN** a user imports the manipulator adapter registry and asks for available adapter keys +- **THEN** DimOS SHALL return the known adapter keys whose lightweight registry metadata is available +- **AND** DimOS MUST NOT import unselected adapter implementation modules only to produce that list. + +#### Scenario: Unrelated adapter remains discoverable +- **GIVEN** an optional binding required by one manipulator adapter is not importable +- **WHEN** a user lists manipulator adapters without selecting that adapter +- **THEN** DimOS SHALL continue discovering unrelated manipulator adapters +- **AND** missing optional dependency errors SHALL NOT prevent unrelated adapter keys from appearing. + +### Requirement: Selected manipulator adapter loading + +DimOS SHALL import and instantiate only the manipulator adapter selected by `adapter_registry.create(name, **kwargs)`. Tests for selected loading SHALL exercise the create call and observe the selected adapter result or actionable error. + +#### Scenario: Creating a selected adapter +- **GIVEN** a registered manipulator adapter key and constructor arguments for that adapter +- **WHEN** a user calls `adapter_registry.create()` with that key +- **THEN** DimOS SHALL resolve the selected adapter implementation +- **AND** DimOS SHALL instantiate the selected adapter with the provided arguments. + +#### Scenario: Unknown adapter name +- **GIVEN** a manipulator adapter key is not registered +- **WHEN** a user calls `adapter_registry.create()` with that key +- **THEN** DimOS SHALL fail with an error that identifies the unknown adapter +- **AND** the error SHALL include the currently available adapter keys. + +#### Scenario: Broken selected adapter registration +- **GIVEN** a manipulator adapter key is registered to an implementation path that cannot be resolved +- **WHEN** a user selects that adapter key +- **THEN** DimOS SHALL fail with an actionable selected-adapter error +- **AND** the error SHALL identify the selected adapter key or its configured implementation path. + +#### Scenario: Registry tests remain behavior-focused +- **GIVEN** a test covers manipulator adapter discovery or selected loading +- **WHEN** it verifies the registry behavior +- **THEN** it SHALL observe available keys, selected instantiation, import side effects, or actionable errors +- **AND** it SHALL avoid asserting registry internals that are not part of the developer-visible contract. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/openarm-rs-adapter-selection/spec.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/openarm-rs-adapter-selection/spec.md new file mode 100644 index 0000000000..03b0ccf27b --- /dev/null +++ b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/openarm-rs-adapter-selection/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: Explicit OpenArm RS adapter selection + +DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. Unit tests for the OpenArm RS adapter SHALL avoid fake `can_motor_control` hardware behavior and cover only constructor-time public metadata and validation behavior. + +#### Scenario: Selecting the Rust-backed OpenArm path +- **GIVEN** a hardware configuration selects the binding-backed OpenArm adapter +- **WHEN** DimOS initializes manipulator hardware through the adapter registry +- **THEN** the configuration SHALL select the adapter using the `openarm_rs` key +- **AND** the selected adapter SHALL represent OpenArm hardware rather than a generic Damiao arm. + +#### Scenario: Binding unavailable for OpenArm RS +- **GIVEN** the `can_motor_control` binding is not importable in the runtime environment +- **WHEN** a user selects the `openarm_rs` adapter +- **THEN** DimOS SHALL fail with a clear selected-adapter missing-binding error +- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding +- **AND** listing manipulator adapters MUST NOT import the OpenArm RS implementation only to discover its adapter key. + +### Requirement: OpenArm RS safety staging + +DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. Unit tests for this path SHALL NOT simulate backend control-library effects with fake hardware classes. + +#### Scenario: Validating before real hardware operation +- **GIVEN** a user wants to run OpenArm hardware through the `openarm_rs` path +- **WHEN** they follow DimOS bring-up guidance +- **THEN** the guidance SHALL start with binding availability and mock or virtual CAN validation +- **AND** it SHALL treat real hardware gravity compensation and trajectory execution as later staged QA steps. + +#### Scenario: OpenArm RS tests remain behavior-focused +- **GIVEN** a test covers OpenArm RS adapter behavior +- **WHEN** it verifies behavior without the real control binding +- **THEN** it SHALL limit coverage to constructor-time metadata, limits, registration, and validation errors +- **AND** it SHALL avoid fake `can_motor_control` robots, buses, arms, state reads, and command effects. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/tasks.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/tasks.md new file mode 100644 index 0000000000..e655036eb4 --- /dev/null +++ b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/tasks.md @@ -0,0 +1,20 @@ +## 1. Implementation + +- [x] 1.1 Audit `dimos/hardware/manipulators/openarm_rs/test_adapter.py`, `dimos/hardware/manipulators/openarm/test_adapter.py`, `dimos/hardware/manipulators/damiao/test_base_adapter.py`, and `dimos/hardware/manipulators/test_registry.py` for tests that only assert construction shape, private fields, full metadata snapshots, or unrelated backend command details. +- [x] 1.2 Delete or collapse tests whose behavior cannot be stated clearly in the test name. +- [x] 1.3 Rewrite retained manipulator adapter tests so each follows setup, execute functionality, and check desired result through public adapter or fake-backend observations. +- [x] 1.4 Preserve behavior coverage while limiting OpenArm RS unit tests to non-control-binding metadata, registration, limits, and validation behavior. +- [x] 1.5 Keep safety-critical command assertions only when they are the smallest meaningful proof of the named safety behavior. + +## 2. OpenSpec Prompt Guidance + +- [x] 2.1 Remove the behavior-focused testing section from `docs/coding-agents/testing.md`. +- [x] 2.2 Update `openspec/schemas/dimos-capability/schema.yaml` tasks instructions with behavior-focused testing guidance. +- [x] 2.3 Update `openspec/schemas/dimos-capability/schema.yaml` apply instructions so implementation agents avoid low-value object-shape tests. + +## 3. Verification + +- [x] 3.1 Run `openspec validate improve-behavior-focused-test-guidelines`. +- [x] 3.2 Run `uv run pytest dimos/hardware/manipulators/openarm_rs/test_adapter.py dimos/hardware/manipulators/openarm/test_adapter.py dimos/hardware/manipulators/damiao/test_base_adapter.py dimos/hardware/manipulators/test_registry.py -q`. +- [x] 3.3 Run `openspec validate improve-behavior-focused-test-guidelines` after OpenSpec prompt changes. +- [x] 3.4 Manually QA the test-quality change by reviewing the final changed tests and confirming each retained test has an explicit setup, execution step, and desired behavioral result. diff --git a/openspec/schemas/dimos-capability/schema.yaml b/openspec/schemas/dimos-capability/schema.yaml index fedb7964ee..cbc073e1c7 100644 --- a/openspec/schemas/dimos-capability/schema.yaml +++ b/openspec/schemas/dimos-capability/schema.yaml @@ -105,6 +105,8 @@ artifacts: - Include docs and validation tasks from docs.md. - Include generated registry tasks when blueprints or module registry inputs change. - Include manual QA through the actual user surface: CLI, TUI, HTTP API, MCP tool, simulation/replay blueprint, hardware procedure, or library driver. + - When adding or revising tests, include tasks that make tests behavior-focused: setup the test, execute the functionality, and check the desired result. Avoid tasks that only construct objects and assert private fields, full metadata snapshots, default tables, or fake backend internals. + - For hardware or adapter tests, prefer public surfaces and observable effects. Do not require fake control-library hardware classes unless the fake is the only meaningful user-surface substitute; if a safety-critical backend detail must be asserted, name the safety behavior and keep the assertion minimal. Typical DimOS validation tasks: - Run `openspec validate `. @@ -125,4 +127,5 @@ apply: instruction: | Read proposal.md, specs, design.md, docs.md, and tasks.md before editing code. Work through pending tasks, mark checkboxes complete as they finish, and keep artifacts current when implementation changes the plan. + When implementing tests, keep them behavior-focused: set up the test, execute functionality, and check the desired result. Do not add low-value tests that only construct objects and assert incidental details. Verify with OpenSpec validation, focused tests, docs checks, and manual QA through the relevant DimOS surface. diff --git a/openspec/specs/behavior-focused-test-guidelines/spec.md b/openspec/specs/behavior-focused-test-guidelines/spec.md new file mode 100644 index 0000000000..91fdbd7cec --- /dev/null +++ b/openspec/specs/behavior-focused-test-guidelines/spec.md @@ -0,0 +1,32 @@ +## Purpose + +Define coding-agent testing guidance that keeps unit tests focused on observable DimOS behavior rather than incidental implementation shape. + +## Requirements + +### Requirement: Behavior-focused unit test guidance + +DimOS SHALL provide coding-agent testing guidance that tells agents to write unit tests around observable behavior rather than incidental object shape. + +#### Scenario: Agent prepares to write a unit test +- **GIVEN** a coding agent is writing or reviewing a unit test +- **WHEN** it consults the DimOS coding-agent testing guidance +- **THEN** the guidance SHALL instruct it to structure the test as setup, execute functionality, and check the desired result +- **AND** the guidance SHALL discourage tests that only construct an object and assert many minor properties. + +#### Scenario: Agent reviews an over-assertive test +- **GIVEN** a proposed test asserts private fields, full metadata snapshots, or every backend command detail +- **WHEN** the agent applies the testing guidance +- **THEN** it SHALL either replace those assertions with a public behavior check or justify the smallest safety-critical assertion needed +- **AND** it SHALL prefer a test name that states the desired behavior being protected. + +### Requirement: Test-quality review checklist + +DimOS SHALL provide a concise checklist for coding agents to evaluate whether a unit test is behavior-focused. + +#### Scenario: Agent self-reviews a new test +- **GIVEN** a coding agent has written a new unit test +- **WHEN** it applies the checklist +- **THEN** the checklist SHALL ask what behavior would break if the test failed +- **AND** it SHALL ask whether the test executed functionality rather than only constructing an object +- **AND** it SHALL ask whether the assertions target public outcomes instead of private implementation details. diff --git a/openspec/specs/dm-motor-manipulator-adapters/spec.md b/openspec/specs/dm-motor-manipulator-adapters/spec.md index f3f6cbf048..2688e9cbf3 100644 --- a/openspec/specs/dm-motor-manipulator-adapters/spec.md +++ b/openspec/specs/dm-motor-manipulator-adapters/spec.md @@ -22,7 +22,7 @@ DimOS SHALL narrow the current binding-backed DMMotor/OpenArm behavior into an e ### Requirement: Binding-backed adapter safety behavior -DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. +DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. Tests for this behavior SHALL verify observable adapter outcomes and safety-relevant command effects rather than broad snapshots of private adapter construction state. #### Scenario: Coherent state reads - **GIVEN** the OpenArm RS adapter is connected through the binding-backed path @@ -42,3 +42,9 @@ DimOS SHALL preserve the safety expectations already required for the binding-ba - **THEN** discovery SHALL continue for adapters that do not require the binding - **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path - **AND** registry listing MUST NOT import the OpenArm RS implementation only to discover its adapter key. + +#### Scenario: Safety tests remain behavior-focused +- **GIVEN** a test protects binding-backed adapter safety behavior +- **WHEN** the test verifies command output or state behavior +- **THEN** it SHALL name the safety behavior being protected +- **AND** it SHALL avoid asserting unrelated private fields or full default tables that are not required to prove that behavior. diff --git a/openspec/specs/manipulator-adapter-discovery/spec.md b/openspec/specs/manipulator-adapter-discovery/spec.md index a6d0b6e231..1cd1a41dfb 100644 --- a/openspec/specs/manipulator-adapter-discovery/spec.md +++ b/openspec/specs/manipulator-adapter-discovery/spec.md @@ -6,7 +6,7 @@ Define lazy manipulator adapter discovery behavior so adapter keys remain listab ### Requirement: Metadata-only manipulator adapter listing -DimOS SHALL list registered manipulator adapter keys without importing unselected adapter implementation modules. +DimOS SHALL list registered manipulator adapter keys without importing unselected adapter implementation modules. Tests for this requirement SHALL verify discovery behavior through registry listing and module-import observability rather than asserting incidental registry object internals. #### Scenario: Listing adapters in a partial installation - **GIVEN** a DimOS environment where one or more optional manipulator hardware SDKs are not installed @@ -22,7 +22,7 @@ DimOS SHALL list registered manipulator adapter keys without importing unselecte ### Requirement: Selected manipulator adapter loading -DimOS SHALL import and instantiate only the manipulator adapter selected by `adapter_registry.create(name, **kwargs)`. +DimOS SHALL import and instantiate only the manipulator adapter selected by `adapter_registry.create(name, **kwargs)`. Tests for selected loading SHALL exercise the create call and observe the selected adapter result or actionable error. #### Scenario: Creating a selected adapter - **GIVEN** a registered manipulator adapter key and constructor arguments for that adapter @@ -42,6 +42,12 @@ DimOS SHALL import and instantiate only the manipulator adapter selected by `ada - **THEN** DimOS SHALL fail with an actionable selected-adapter error - **AND** the error SHALL identify the selected adapter key or its configured implementation path. +#### Scenario: Registry tests remain behavior-focused +- **GIVEN** a test covers manipulator adapter discovery or selected loading +- **WHEN** it verifies the registry behavior +- **THEN** it SHALL observe available keys, selected instantiation, import side effects, or actionable errors +- **AND** it SHALL avoid asserting registry internals that are not part of the developer-visible contract. + ### Requirement: Stable manipulator adapter keys DimOS SHALL preserve existing built-in manipulator adapter keys across the lazy discovery migration. diff --git a/openspec/specs/openarm-rs-adapter-selection/spec.md b/openspec/specs/openarm-rs-adapter-selection/spec.md index 5b227ed7a6..1bb0e9fbe6 100644 --- a/openspec/specs/openarm-rs-adapter-selection/spec.md +++ b/openspec/specs/openarm-rs-adapter-selection/spec.md @@ -6,7 +6,7 @@ Define the explicit user-facing selection and safety staging surface for the Ope ### Requirement: Explicit OpenArm RS adapter selection -DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. +DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. Unit tests for the OpenArm RS adapter SHALL avoid fake `can_motor_control` hardware behavior and cover only constructor-time public metadata and validation behavior. #### Scenario: Selecting the Rust-backed OpenArm path - **GIVEN** a hardware configuration selects the binding-backed OpenArm adapter @@ -33,10 +33,16 @@ DimOS SHALL expose binding-backed OpenArm runnable blueprints with names that id ### Requirement: OpenArm RS safety staging -DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. +DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. Unit tests for this path SHALL NOT simulate backend control-library effects with fake hardware classes. #### Scenario: Validating before real hardware operation - **GIVEN** a user wants to run OpenArm hardware through the `openarm_rs` path - **WHEN** they follow DimOS bring-up guidance - **THEN** the guidance SHALL start with binding availability and mock or virtual CAN validation - **AND** it SHALL treat real hardware gravity compensation and trajectory execution as later staged QA steps. + +#### Scenario: OpenArm RS tests remain behavior-focused +- **GIVEN** a test covers OpenArm RS adapter behavior +- **WHEN** it verifies behavior without the real control binding +- **THEN** it SHALL limit coverage to constructor-time metadata, limits, registration, and validation errors +- **AND** it SHALL avoid fake `can_motor_control` robots, buses, arms, state reads, and command effects. From 7b29dcd4715cc6b0e4b32013fb93dd12a774719c Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 8 Jun 2026 16:39:46 -0700 Subject: [PATCH 031/110] chore: remove openspec stuff --- .gitignore | 2 - docs/coding-agents/index.md | 1 - docs/development/openspec.md | 102 -------------- docs/docs.json | 1 - .../.openspec.yaml | 2 - .../design.md | 120 ---------------- .../docs.md | 30 ---- .../proposal.md | 40 ------ .../dm-motor-manipulator-adapters/spec.md | 56 -------- .../gravity-compensation-control/spec.md | 43 ------ .../specs/manipulation-stack/spec.md | 31 ----- .../tasks.md | 50 ------- .../.openspec.yaml | 2 - .../design.md | 122 ---------------- .../docs.md | 28 ---- .../proposal.md | 40 ------ .../damiao-adapter-metadata-docs/spec.md | 27 ---- .../dm-motor-manipulator-adapters/spec.md | 39 ------ .../openarm-adapter-compatibility/spec.md | 39 ------ .../openarm-rs-adapter-selection/spec.md | 37 ----- .../tasks.md | 35 ----- .../.openspec.yaml | 2 - .../design.md | 112 --------------- .../docs.md | 28 ---- .../proposal.md | 37 ----- .../dm-motor-manipulator-adapters/spec.md | 40 ------ .../manipulator-adapter-discovery/spec.md | 49 ------- .../openarm-rs-adapter-selection/spec.md | 38 ----- .../tasks.md | 40 ------ .../.openspec.yaml | 2 - .../design.md | 126 ----------------- .../docs.md | 25 ---- .../proposal.md | 39 ------ .../specs/damiao-arm-adapter-refactor/spec.md | 43 ------ .../openarm-adapter-compatibility/spec.md | 43 ------ .../tasks.md | 43 ------ .../.openspec.yaml | 2 - .../design.md | 75 ---------- .../docs.md | 22 --- .../proposal.md | 37 ----- .../behavior-focused-test-guidelines/spec.md | 28 ---- .../dm-motor-manipulator-adapters/spec.md | 30 ---- .../manipulator-adapter-discovery/spec.md | 45 ------ .../openarm-rs-adapter-selection/spec.md | 34 ----- .../tasks.md | 20 --- openspec/config.yaml | 45 ------ openspec/schemas/dimos-capability/schema.yaml | 131 ------------------ .../dimos-capability/templates/design.md | 35 ----- .../dimos-capability/templates/docs.md | 19 --- .../dimos-capability/templates/proposal.md | 32 ----- .../dimos-capability/templates/spec.md | 16 --- .../dimos-capability/templates/tasks.md | 15 -- .../behavior-focused-test-guidelines/spec.md | 32 ----- .../damiao-adapter-metadata-docs/spec.md | 31 ----- .../dm-motor-manipulator-adapters/spec.md | 50 ------- .../manipulator-adapter-discovery/spec.md | 59 -------- .../openarm-adapter-compatibility/spec.md | 43 ------ .../openarm-rs-adapter-selection/spec.md | 48 ------- 58 files changed, 2363 deletions(-) delete mode 100644 docs/development/openspec.md delete mode 100644 openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/design.md delete mode 100644 openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/docs.md delete mode 100644 openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/proposal.md delete mode 100644 openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/tasks.md delete mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/design.md delete mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/docs.md delete mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/proposal.md delete mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/damiao-adapter-metadata-docs/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/dm-motor-manipulator-adapters/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-adapter-compatibility/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-rs-adapter-selection/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/tasks.md delete mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/design.md delete mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/docs.md delete mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/proposal.md delete mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/dm-motor-manipulator-adapters/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/manipulator-adapter-discovery/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/openarm-rs-adapter-selection/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/tasks.md delete mode 100644 openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/design.md delete mode 100644 openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/docs.md delete mode 100644 openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/proposal.md delete mode 100644 openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md delete mode 100644 openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/tasks.md delete mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/design.md delete mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/docs.md delete mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/proposal.md delete mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/behavior-focused-test-guidelines/spec.md delete mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/dm-motor-manipulator-adapters/spec.md delete mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/manipulator-adapter-discovery/spec.md delete mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/openarm-rs-adapter-selection/spec.md delete mode 100644 openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/tasks.md delete mode 100644 openspec/config.yaml delete mode 100644 openspec/schemas/dimos-capability/schema.yaml delete mode 100644 openspec/schemas/dimos-capability/templates/design.md delete mode 100644 openspec/schemas/dimos-capability/templates/docs.md delete mode 100644 openspec/schemas/dimos-capability/templates/proposal.md delete mode 100644 openspec/schemas/dimos-capability/templates/spec.md delete mode 100644 openspec/schemas/dimos-capability/templates/tasks.md delete mode 100644 openspec/specs/behavior-focused-test-guidelines/spec.md delete mode 100644 openspec/specs/damiao-adapter-metadata-docs/spec.md delete mode 100644 openspec/specs/dm-motor-manipulator-adapters/spec.md delete mode 100644 openspec/specs/manipulator-adapter-discovery/spec.md delete mode 100644 openspec/specs/openarm-adapter-compatibility/spec.md delete mode 100644 openspec/specs/openarm-rs-adapter-selection/spec.md diff --git a/.gitignore b/.gitignore index 27770d6c8e..36f5106575 100644 --- a/.gitignore +++ b/.gitignore @@ -63,10 +63,8 @@ yolo11n.pt # symlink one of .envrc.* if you'd like to use .envrc .claude -.opencode/ **/CLAUDE.md .direnv/ -.omo/ /logs diff --git a/docs/coding-agents/index.md b/docs/coding-agents/index.md index 5ac7c854a7..ff778ac5cf 100644 --- a/docs/coding-agents/index.md +++ b/docs/coding-agents/index.md @@ -3,7 +3,6 @@ ├── worktrees.md (creating provisioned worktrees with `bin/worktree`) ├── style.md (code style guidelines for dimos) ├── testing.md (docs about writing tests) -├── ../development/openspec.md (OpenSpec behavior-spec workflow) ├── docs (these are docs about writing docs) │   ├── codeblocks.md │   ├── doclinks.md diff --git a/docs/development/openspec.md b/docs/development/openspec.md deleted file mode 100644 index 280eb0f57e..0000000000 --- a/docs/development/openspec.md +++ /dev/null @@ -1,102 +0,0 @@ -# OpenSpec Workflow - -DimOS uses OpenSpec as the checked-in planning layer for behavior changes. OpenSpec artifacts live under `openspec/` and should describe what the system is supposed to do, why it is changing, and how contributors or agents should validate the work. - -## Terminology - -Keep these two meanings separate: - -- **OpenSpec capability spec**: Markdown requirements under `openspec/specs//spec.md`. These describe observable behavior and acceptance scenarios. -- **DimOS Spec**: Python Protocol/RPC contracts in files like `dimos/navigation/navigation_spec.py` or `dimos/manipulation/control/arm_driver_spec.py`. These describe module interfaces for code wiring. - -Use "OpenSpec capability spec" in prose when there is any chance of confusion. - -## Schema - -The project uses the `dimos-capability` schema configured in `openspec/config.yaml`. - -The artifact flow is: - -```text -proposal - ├── specs - ├── design - └── docs - └── tasks -``` - -| Artifact | Purpose | -|---|---| -| `proposal.md` | Intent, scope, affected DimOS surfaces, and capability impact. | -| `specs//spec.md` | Behavior-first requirements and scenarios. | -| `design.md` | Module, stream, blueprint, skill/MCP, safety, and rollout decisions. | -| `docs.md` | Documentation impact and doc validation plan. | -| `tasks.md` | Implementation, docs, verification, and manual QA checklist. | - -## When to create a change - -Create an OpenSpec change when work changes observable behavior, public CLI/API/MCP behavior, robot behavior, hardware/simulation/replay workflows, docs that users rely on, or cross-module architecture. - -Do not create a change for a purely mechanical refactor, typo fix, or internal cleanup unless it changes behavior or needs cross-session planning context. - -## Writing specs - -OpenSpec capability specs are behavior contracts, not implementation plans. - -Good spec content: - -- User- or developer-visible behavior. -- Public CLI/API/MCP tool behavior. -- Stream or message behavior that downstream modules rely on. -- Robot safety constraints and hardware/simulation/replay expectations. -- Scenarios that can be tested or manually verified. - -Avoid in specs: - -- Private class/function names. -- Generated-file mechanics. -- Library choices and wiring details. -- Step-by-step implementation tasks. - -Put those details in `design.md` or `tasks.md`. - -## Capability names - -Prefer behavior-domain names over code names. Useful starting points: - -- `module-system` -- `blueprint-composition` -- `cli-lifecycle` -- `agent-skills-mcp` -- `configuration` -- `navigation-stack` -- `manipulation-stack` -- `hardware-adapters` -- `simulation-replay` -- `documentation-system` - -Add specs progressively as changes need them. Do not try to backfill the whole project at once. - -## Validation - -Use OpenSpec validation before implementation and before archiving: - -```bash skip -openspec schema validate dimos-capability -openspec validate -openspec templates --json -``` - -For documentation changes, also run the relevant doc checks from [Writing Docs](/docs/development/writing_docs.md): - -```bash skip -md-babel-py run -``` - -When a change touches blueprint names, module-level blueprint variables, or module registry inputs, run: - -```bash skip -pytest dimos/robot/test_all_blueprints_generation.py -``` - -Then run focused tests for the changed code and manually QA through the actual surface: CLI command, MCP tool, HTTP API, simulation/replay blueprint, hardware procedure, or library driver. diff --git a/docs/docs.json b/docs/docs.json index f0064c9ab9..58da2ff6a1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,7 +144,6 @@ "group": "Development", "pages": [ "development/conventions", - "development/openspec", "development/testing", "development/docker", "development/grid_testing", diff --git a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/.openspec.yaml b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/.openspec.yaml deleted file mode 100644 index c66f6f490c..0000000000 --- a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: dimos-capability -created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/design.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/design.md deleted file mode 100644 index 542e1e6628..0000000000 --- a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/design.md +++ /dev/null @@ -1,120 +0,0 @@ -## Context - -DimOS currently supports manipulators through the `ManipulatorAdapter` protocol, the manipulator adapter registry, `HardwareComponent.adapter_type`, and the `ControlCoordinator` read/compute/write loop. OpenArm hardware already has an `openarm` adapter that owns a custom Python SocketCAN driver, sets MIT mode, computes Pinocchio-based gravity feed-forward, and preserves existing OpenArm blueprints. - -The `can-motor-control` package exposes a Python binding for DMMotor/Damiao hardware. Its Python API owns robot lifecycle and bus ticks through `Robot.connect()`, `Robot.enable()`, repeated `Robot.tick(...)`, group state reads, and arm commands. This change must use that Python binding from DimOS and expose the published package through the manipulation extra. - -Gravity compensation should follow the existing OpenArm adapter pattern: it is computed in-place inside adapter command writes and controlled with an adapter flag. The gravity-compensation-only helper method must not act like a trajectory or stiff position-hold controller. - -## Goals / Non-Goals - -**Goals:** - -- Add a DMMotor manipulator adapter path named `DMMotorArm` that uses the `can_motor_control` Python binding. -- Register the adapter under a stable DimOS adapter key, expected to be `dm_motor_arm`. -- Preserve existing `openarm` adapter and blueprint behavior unless a later change explicitly migrates it. -- Provide adapter-level gravity compensation that sends model-based feed-forward torque and can be enabled or disabled with an adapter flag. -- Add the published `can-motor-control` dependency to the manipulation extra; environments selecting the adapter should install `dimos[manipulation]`. -- Document and validate safe hardware bring-up, especially enable/disable, state freshness, and shutdown behavior. - -**Non-Goals:** - -- Do not call the Rust crates directly from DimOS. -- Do not replace the existing `openarm` adapter registration in this change. -- Do not build or vendor the Rust binding in DimOS; consume the published `can-motor-control` package. -- Do not expose new skills or MCP tools. -- Do not introduce a separate gravity-compensation module or blueprint. - -## DimOS Architecture - -The adapter should live in the existing manipulator hardware layer: - -```text -ControlCoordinator - -> HardwareComponent(adapter_type="dm_motor_arm") - -> manipulator adapter registry - -> DMMotorArm - -> can_motor_control Python binding - -> SocketCAN / MockCanBus / vcan-backed DMMotor hardware -``` - -The `DMMotorArm` class should satisfy `ManipulatorAdapter` for coordinator-compatible use. It should be discovered through the existing manipulator registry hook and created from `HardwareComponent` fields such as `address`, `dof`, `hardware_id`, and `adapter_kwargs`. The adapter module should not import `can_motor_control` at module import time; it should import lazily when the adapter is constructed or connected so unrelated DimOS users do not lose registry discovery when the binding is absent. - -The coordinator path should use existing `joint_state` output and command routing. The adapter must reconcile DimOS' separate read/write calls with `can_motor_control`'s queued-command plus `Robot.tick(...)` model by owning tick/cache semantics internally. Reads should return coherent cached positions, velocities, and efforts for one cycle rather than independently ticking three times. Writes should queue commands and flush/update at a controlled point so the CAN bus is not double-loaded. - -Gravity compensation lives in `DMMotorArm` rather than a separate module. With `gravity_comp=True`, adapter position writes compute `tau_g(q)` from the current measured joint state and send MIT commands with gravity feed-forward. The adapter intentionally supports position and effort semantics only; velocity control is rejected because nonzero gains would hold the supplied `q` anchor. The adapter also exposes a gravity-compensation-only helper that sends MIT commands with `kp=0`, configurable low/no damping, and gravity torque for direct bring-up tests: - -```text -ControlCoordinator / caller - -> DMMotorArm adapter - -> read current q/dq through can_motor_control tick/cache - -> compute tau_g(q) - -> send MIT position command with adapter gains or effort/gravity-only kp=0 -``` - -This follows the existing OpenArm adapter style and avoids a duplicate lifecycle thread or standalone gravity-compensation blueprint. - -No new DimOS `Spec` Protocol is required for this change unless implementation needs RPC injection between modules. No skill/MCP exposure is planned. If new runnable blueprints are added, `dimos/robot/all_blueprints.py` must be regenerated with `pytest dimos/robot/test_all_blueprints_generation.py`. - -## Decisions - -- **Use the Python binding, not Rust crates directly.** This keeps the integration inside DimOS' Python module/adapter system and matches the user's requested surface. -- **Create a new `dm_motor_arm` adapter instead of replacing `openarm`.** The current OpenArm adapter includes hardware-specific limits, MIT-mode setup, thermal reporting, and gravity compensation behavior. Replacing it would be a silent compatibility and safety change. -- **Expose the binding through `dimos[manipulation]`.** The adapter should clearly fail when selected and the Python package is unavailable, while the manipulation extra should install the published `can-motor-control` package on supported platforms. -- **Use lazy binding import.** Registry discovery should remain healthy even when `can_motor_control` is not installed. -- **Treat `Robot.tick(...)` as adapter-owned.** The adapter should ensure one coherent state snapshot per cycle and avoid ticking once per position/velocity/effort read. -- **Provide gravity compensation in-place through the adapter.** This matches the existing OpenArm implementation and keeps lifecycle/tick ownership inside one hardware adapter. -- **Use model-based gravity compensation with zero position stiffness for effort/gravity-only commands.** Gravity-only helper calls should send feed-forward torque and optional damping, not position targets with nonzero stiffness. - -## Safety / Simulation / Replay - -Hardware assumptions: - -- DMMotor/Damiao hardware is connected through Linux SocketCAN with CAN-FD enabled by default, or a compatible mock/vcan backend supported by the Python binding. -- The Python binding is installed in the active runtime environment, typically through `dimos[manipulation]`. -- Joint ordering in DimOS configuration matches the arm group ordering in the binding/config. -- Gravity compensation model and hardware joint signs/offsets are valid before enabling gravity-only mode on real hardware. - -Safety constraints: - -- Disable motors on shutdown, interruption, and disconnect. -- Do not auto-install dependencies or silently select a different adapter. -- Do not use position-hold gains in gravity-compensation-only helper commands. -- Start hardware QA at low rates with one motor or mock/vcan before full-arm bring-up. -- Make binding-unavailable failures explicit before attempting hardware access. - -Simulation/replay: - -- Replay is out of scope. -- Mock/vcan validation through `can_motor_control.MockCanBus` or SocketCAN virtual interfaces should be supported for development. -- Existing `openarm` mock/planner blueprints remain available and unchanged. - -Manual QA surface: - -- Registry discovery includes `dm_motor_arm` when the adapter module is present. -- Missing binding produces a clear selected-adapter error. -- Mock/vcan adapter cycles read state and accepts commands without hardware. -- Adapter gravity compensation can be enabled/disabled through `gravity_comp`, computes feed-forward torque in command writes, and the gravity-only helper keeps joints manually movable with `kp=0`. - -## Risks / Trade-offs - -- **Binding availability and API stability:** `can-motor-control` is a new binding. Mitigation: document expected availability through `dimos[manipulation]`, verify import behavior in QA, and keep adapter usage explicit. -- **Tick timing:** The binding flushes queued commands and receives feedback through `Robot.tick(...)`. Mitigation: centralize tick/cache behavior in the adapter and avoid multiple ticks per coordinator cycle. -- **Gravity compensation correctness:** Incorrect model, joint signs, or offsets can push hardware unexpectedly. Mitigation: require mock/vcan and low-rate bring-up, document validation, and avoid position stiffness in gravity-only helper mode. - -## Migration / Rollout - -1. Add the new adapter and in-place gravity-compensation behavior without changing existing `openarm` registrations. -2. Add opt-in DMMotor/OpenArm-style blueprints using `adapter_type="dm_motor_arm"`. -3. Regenerate blueprint registry if runnable blueprints are added. -4. Update OpenArm/manipulation docs to distinguish legacy `openarm` and new DMMotor binding paths. -5. Validate mock/vcan, then one-motor hardware, then full-arm adapter gravity compensation. -6. Only consider migrating existing OpenArm blueprints after hardware parity and safety behavior are proven. - -## Open Questions - -- Should `dm_motor_arm` expose only `DMMotorArm`, or should it also provide an alias such as `dmmotorarm` for CLI convenience? -- Should the adapter build robots from a binding TOML config path, from DimOS `RobotConfig` fields, or support both? -- What model source should gravity compensation use for non-OpenArm DMMotor arms? -- What default damping, if any, is safe for gravity-compensation-only mode while preserving free joint motion? -- Should torque routing be generalized through `ControlCoordinator` in a later change? diff --git a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/docs.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/docs.md deleted file mode 100644 index 7e862cd122..0000000000 --- a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/docs.md +++ /dev/null @@ -1,30 +0,0 @@ -## User-Facing Docs - -- Update `docs/capabilities/manipulation/openarm_integration.md` to describe two OpenArm/DMMotor paths: - - existing `openarm` adapter with in-tree custom CAN driver, - - new `dm_motor_arm` adapter using the `can_motor_control` Python binding installed by `dimos[manipulation]` on supported platforms. -- Update the OpenArm quick-start tables after blueprint names are finalized, including the opt-in DMMotor coordinator and the distinction from existing OpenArm trajectory/planner blueprints. -- Document that `dimos[manipulation]` installs the published `can-motor-control` package on supported platforms, and selecting the adapter fails clearly if `can_motor_control` is not importable. -- Document adapter-level gravity-compensation behavior: it computes gravity feed-forward in-place when enabled and can be disabled with `gravity_comp=False`. -- Add hardware bring-up guidance for mock/vcan, one-motor validation, full-arm state monitor, gravity compensation, and safe shutdown. - -## Contributor Docs - -- Update contributor-facing manipulation hardware guidance if the adapter introduces a reusable pattern for lazy optional SDK imports or binding-backed adapters. -- If new blueprint registry entries are added, mention the required generation command in implementation notes or relevant development docs: `pytest dimos/robot/test_all_blueprints_generation.py`. -- No broader development-process documentation is expected beyond the manipulation extra dependency update. - -## Coding-Agent Docs - -- Update `docs/coding-agents/` only if there is an existing manipulation or hardware-adapter guide that should mention the `can_motor_control` binding path and gravity-compensation QA steps. -- No `AGENTS.md` update is required unless implementation reveals a new repo-wide convention. - -## Doc Validation - -- Run documentation link validation for changed docs if available in the project workflow. -- Run `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable Python blocks are added or changed. -- Run `pytest dimos/robot/test_all_blueprints_generation.py` if new runnable blueprints are added and `dimos/robot/all_blueprints.py` changes. - -## No Docs Needed - -Documentation is needed because this change affects real hardware bring-up, adapter selection, dependency expectations, and operator-visible gravity compensation behavior. diff --git a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/proposal.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/proposal.md deleted file mode 100644 index b95e90de25..0000000000 --- a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/proposal.md +++ /dev/null @@ -1,40 +0,0 @@ -## Why - -DimOS already has OpenArm support, but the current adapter owns a custom Python CAN driver because there was no stable Python SDK surface for Damiao/OpenArm motors. The `can-motor-control` Python package provides a Rust-backed control library with a Python API for building robots, opening SocketCAN buses, ticking control loops, reading motor state, and commanding arm groups. Using the Python binding keeps DimOS integration in the existing Python manipulator adapter layer while avoiding a direct Rust integration in this change. - -This change also needs built-in gravity compensation for DMMotor-based arms. The existing OpenArm adapter uses model-based gravity compensation inside MIT commands; the new adapter path should follow that pattern in-place through an adapter flag, applying feed-forward gravity torque without introducing a separate gravity-compensation module. - -## What Changes - -- Add a new DMMotor manipulator adapter behavior that uses the `can_motor_control` Python binding API rather than calling Rust crates directly. -- Add support for configuring DMMotor arm hardware through the existing manipulator adapter registry and ControlCoordinator hardware configuration path. -- Add an adapter-level gravity-compensation operating path for DMMotor arms that sends gravity feed-forward torque while keeping the behavior opt-in/configurable through adapter kwargs. -- Preserve existing `openarm` adapter behavior and blueprints unless explicitly migrated later; this change does not silently replace the current OpenArm custom CAN adapter. -- Add the published `can-motor-control` package to the manipulation extra so environments selecting the adapter can install it through `dimos[manipulation]`. -- Mark hardware safety behavior explicitly: the adapter must stop or disable safely on shutdown and must avoid unintended stiff position-hold behavior in gravity-compensation-only commands. - -## Affected DimOS Surfaces - -- Modules/streams: Manipulator adapter behavior behind `ManipulatorAdapter`; ControlCoordinator read/write behavior through existing `joint_state` and command routing surfaces. -- Blueprints/CLI: New DMMotor/OpenArm-style hardware blueprint entry point for coordinator use through `dimos run` once registered. -- Skills/MCP: No direct skill or MCP tool changes planned for this proposal. -- Hardware/simulation/replay: Real SocketCAN DMMotor/OpenArm hardware bring-up; mock/vcan validation through the `can_motor_control` Python binding where available; no replay changes planned. -- Docs/generated registries: Manipulation/OpenArm documentation, blueprint registry generation if new runnable blueprints are added, and adapter registry discovery behavior. - -## Capabilities - -### New Capabilities - -- `dm-motor-manipulator-adapters`: Covers DMMotor arm adapter behavior through the Python binding, including lifecycle, state reads, command writes, binding availability assumptions, and safe shutdown. -- `gravity-compensation-control`: Covers gravity-compensation-only behavior for manipulators, including operator-visible expectations that joints remain free to move while gravity torque is compensated. -- `manipulation-stack`: Covers manipulation-stack integration behavior for DMMotor hardware through DimOS blueprints and coordinator-compatible surfaces. - -### Modified Capabilities - -- None. - -## Impact - -Users gain a new path for DMMotor/OpenArm-style hardware that relies on the `can-motor-control` Python package instead of maintaining another in-tree low-level CAN implementation. Developers can keep the integration inside the established Python adapter registry and ControlCoordinator flow, while future design work can decide when or whether existing OpenArm blueprints should migrate. - -Compatibility risk is primarily around hardware safety, binding availability, joint ordering, tick timing, and gravity compensation semantics. The dependency is exposed through `dimos[manipulation]`, and selecting the adapter still fails explicitly if `can_motor_control` is not importable. Documentation and QA must cover mock/vcan validation, one-motor bring-up, full-arm state monitoring, adapter gravity-compensation behavior, and shutdown/disable behavior on interruption. diff --git a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md deleted file mode 100644 index 54c01b26b2..0000000000 --- a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/dm-motor-manipulator-adapters/spec.md +++ /dev/null @@ -1,56 +0,0 @@ -## ADDED Requirements - -### Requirement: DMMotor adapter selection - -DimOS SHALL provide an opt-in DMMotor manipulator adapter path for DMMotor/Damiao arms that uses the `can_motor_control` Python binding as its hardware API surface. - -#### Scenario: Selecting the DMMotor adapter -- **GIVEN** a manipulator hardware configuration selects the DMMotor adapter type -- **AND** the `can_motor_control` Python binding is available in the runtime environment -- **WHEN** the hardware is initialized -- **THEN** DimOS SHALL create the DMMotor adapter through the manipulator adapter registry -- **AND** the adapter SHALL use the Python binding rather than direct Rust crate calls. - -#### Scenario: Binding unavailable -- **GIVEN** a manipulator hardware configuration selects the DMMotor adapter type -- **AND** the `can_motor_control` Python binding is not importable -- **WHEN** the hardware is initialized -- **THEN** DimOS SHALL fail with an explicit error indicating that the Python binding is unavailable -- **AND** DimOS SHALL indicate that the binding is provided by the `dimos[manipulation]` extra on supported platforms. - -### Requirement: DMMotor adapter lifecycle safety - -The DMMotor adapter SHALL expose safe lifecycle behavior for connection, enablement, state reads, command writes, and shutdown. - -#### Scenario: Clean lifecycle -- **GIVEN** a configured DMMotor arm with available hardware or mock transport -- **WHEN** DimOS connects, enables, reads state, writes commands, and stops the adapter -- **THEN** the adapter SHALL perform those lifecycle steps through the Python binding -- **AND** the adapter SHALL disable the motors during stop or disconnect. - -#### Scenario: Interrupted operation -- **GIVEN** a DMMotor arm is enabled through DimOS -- **WHEN** the owning module or blueprint is stopped or interrupted -- **THEN** DimOS SHALL attempt to disable the motors before releasing the binding object -- **AND** shutdown failures SHALL be surfaced or logged without masking the need for safe operator intervention. - -### Requirement: DMMotor state and command compatibility - -The DMMotor adapter SHALL present manipulator state and position/effort command behavior compatible with DimOS manipulator control surfaces. - -#### Scenario: Reading coherent joint state -- **GIVEN** DimOS requests joint positions, velocities, and efforts for a DMMotor arm during one control cycle -- **WHEN** the adapter reads from the Python binding -- **THEN** the returned values SHALL represent one coherent state snapshot in DimOS joint order -- **AND** repeated per-field reads SHALL NOT independently advance the hardware loop multiple times for the same cycle. - -#### Scenario: Commanding joints -- **GIVEN** DimOS sends a supported position or effort command to a connected DMMotor arm -- **WHEN** the adapter forwards the command through the Python binding -- **THEN** the command SHALL be applied using SI units expected by DimOS -- **AND** the adapter SHALL preserve joint ordering between the DimOS hardware configuration and the binding arm group. - -#### Scenario: Rejecting velocity commands -- **GIVEN** DimOS sends a velocity command to a connected DMMotor arm -- **WHEN** the adapter evaluates the command -- **THEN** the adapter SHALL reject the command rather than emulate velocity with nonzero MIT position gains. diff --git a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md deleted file mode 100644 index 4f52860798..0000000000 --- a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/gravity-compensation-control/spec.md +++ /dev/null @@ -1,43 +0,0 @@ -## ADDED Requirements - -### Requirement: Gravity-compensation-only operation - -DimOS SHALL provide adapter-level gravity-compensation behavior for supported manipulators that compensates gravity without requiring a separate gravity-compensation module. - -#### Scenario: Starting gravity compensation -- **GIVEN** a supported DMMotor arm is configured with `gravity_comp=True` -- **WHEN** the adapter receives a supported position, effort, or gravity-only command -- **THEN** DimOS SHALL apply model-based gravity feed-forward torque for the current joint configuration -- **AND** DimOS SHALL keep gravity compensation in the adapter command path rather than a standalone module. - -#### Scenario: Joints remain free to move -- **GIVEN** the adapter gravity-only helper is active -- **WHEN** a person or external force moves a joint within the safe operating range -- **THEN** the arm SHALL remain manually movable -- **AND** DimOS SHALL send zero position stiffness with configurable low/no damping and gravity feed-forward from the current measured joint state. - -### Requirement: Gravity-compensation safety - -Adapter gravity-compensation operation SHALL make hardware safety expectations explicit and fail safe on shutdown. - -#### Scenario: Stopping gravity compensation -- **GIVEN** adapter gravity compensation is enabled -- **WHEN** the adapter is stopped, disconnected, or disabled -- **THEN** DimOS SHALL disable or otherwise stop commanding the arm through the hardware binding -- **AND** the operator-visible result SHALL be a safe shutdown rather than a continued background control loop. - -#### Scenario: Invalid or stale state -- **GIVEN** gravity compensation requires current joint state -- **WHEN** DimOS cannot obtain valid or fresh joint state from the hardware binding -- **THEN** DimOS SHALL avoid sending a gravity-compensation command based on invalid state -- **AND** DimOS SHALL surface the condition as a fault, warning, or stopped state for operator action. - -### Requirement: Gravity-compensation configuration - -DimOS SHALL expose gravity compensation through adapter configuration rather than a separate runnable gravity-compensation blueprint. - -#### Scenario: Disabling adapter gravity compensation -- **GIVEN** a supported DMMotor arm is configured with `gravity_comp=False` -- **WHEN** the adapter receives position commands -- **THEN** DimOS SHALL send MIT position commands using configured `kp/kd` gains -- **AND** DimOS SHALL not add model gravity feed-forward torque to those commands. diff --git a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md deleted file mode 100644 index 393176fef1..0000000000 --- a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/specs/manipulation-stack/spec.md +++ /dev/null @@ -1,31 +0,0 @@ -## ADDED Requirements - -### Requirement: Non-breaking DMMotor integration - -DimOS SHALL add DMMotor adapter and gravity-compensation behavior without silently changing existing OpenArm adapter behavior. - -#### Scenario: Existing OpenArm blueprints remain stable -- **GIVEN** an existing OpenArm blueprint selects the current OpenArm adapter -- **WHEN** this change is present -- **THEN** the blueprint SHALL continue to use the existing OpenArm adapter unless explicitly changed -- **AND** users SHALL be able to opt into the DMMotor adapter through a distinct adapter or blueprint selection. - -### Requirement: Manipulation hardware bring-up path - -DimOS SHALL support a staged bring-up path for DMMotor manipulators before normal trajectory execution. - -#### Scenario: Staged validation before full arm use -- **GIVEN** a developer or operator is validating DMMotor hardware -- **WHEN** they follow the documented DimOS bring-up path -- **THEN** the path SHALL allow validation with mock or virtual CAN transport before real hardware -- **AND** the path SHALL separate state monitoring, gravity compensation, and trajectory execution into distinct operator-visible steps. - -### Requirement: Blueprint registry visibility - -DimOS SHALL make new DMMotor manipulation blueprints discoverable through the normal blueprint listing surface when runnable blueprints are added. - -#### Scenario: Listing new blueprints -- **GIVEN** DMMotor runnable blueprints have been added -- **WHEN** a user runs the DimOS blueprint listing command -- **THEN** the new DMMotor blueprints SHALL appear with names that distinguish gravity compensation from trajectory-control operation -- **AND** generated blueprint registry files SHALL be kept current. diff --git a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/tasks.md b/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/tasks.md deleted file mode 100644 index 37ff224d01..0000000000 --- a/openspec/changes/archive/2026-06-06-add-dm-motor-arm-adapter/tasks.md +++ /dev/null @@ -1,50 +0,0 @@ -## 1. Adapter Implementation - -- [x] 1.1 Create `dimos/hardware/manipulators/dm_motor_arm/adapter.py` with `DMMotorArm` implementing `ManipulatorAdapter`, registered as `dm_motor_arm`. -- [x] 1.2 Add lazy `can_motor_control` Python binding import inside adapter construction/connect paths so registry discovery does not fail when the binding is absent. -- [x] 1.3 Implement DMMotor lifecycle methods for connect, disconnect, enable, disable, clear error, state reads, and supported position/effort command writes through the Python binding. -- [x] 1.4 Implement adapter-owned tick/cache behavior so one DimOS state-read cycle returns coherent position, velocity, and effort values without independently ticking per field. -- [x] 1.5 Implement binding-unavailable and lifecycle-error handling with explicit selected-adapter errors when `can_motor_control` is unavailable. -- [x] 1.6 Add configuration support for binding robot construction from an existing binding TOML path and/or DimOS adapter kwargs, preserving DimOS joint ordering and defaulting CAN-FD on through `canfd=True`. -- [x] 1.7 Preserve existing `openarm` adapter registration and behavior; do not migrate current OpenArm blueprints unless they explicitly opt into `dm_motor_arm`. - -## 2. Gravity Compensation - -- [x] 2.1 Add model-based gravity compensation support in the DMMotor adapter using the current measured joint state and configured robot model. -- [x] 2.2 Ensure gravity-compensation-only commands use zero position stiffness and configurable low/no damping so joints remain free to move. -- [x] 2.3 Add stale/invalid-state handling that avoids sending gravity compensation commands based on invalid state and surfaces an operator-visible warning or fault. -- [x] 2.4 Add shutdown handling for gravity compensation that disables or stops commanding the arm on stop, interruption, and disconnect. - -## 3. Blueprints and Registry - -- [x] 3.1 Add an opt-in DMMotor hardware blueprint using `adapter_type="dm_motor_arm"` without replacing existing OpenArm blueprints. -- [x] 3.2 Add adapter kwargs for enabling/disabling in-place DMMotor gravity compensation. -- [x] 3.3 Keep blueprint names clear enough for `dimos list` to distinguish the DMMotor coordinator from existing OpenArm coordinator operation. -- [x] 3.4 Regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py` if new runnable blueprints are added. - -## 4. Tests - -- [x] 4.1 Add unit tests that adapter registry discovery includes `dm_motor_arm` without requiring top-level `can_motor_control` import success. -- [x] 4.2 Add missing-binding tests showing selected DMMotor adapter use fails with a clear message and does not auto-install dependencies. -- [x] 4.3 Add adapter lifecycle tests with a fake or mocked binding robot covering connect, enable, read, write, disable, and disconnect order. -- [x] 4.4 Add tick/cache tests proving one DimOS state-read cycle does not call the binding tick once per position, velocity, and effort read. -- [x] 4.5 Add command-shape/order tests for DMMotor position, effort, unsupported velocity, and MIT/gravity-compensation commands in DimOS joint order. -- [x] 4.6 Add gravity-compensation tests proving commands use feed-forward torque with zero position stiffness and avoid sending commands on stale state. - -## 5. Documentation - -- [x] 5.1 Update `docs/capabilities/manipulation/openarm_integration.md` to document the existing `openarm` adapter and new `dm_motor_arm` Python-binding path separately. -- [x] 5.2 Document that `dimos[manipulation]` installs `can-motor-control` on supported platforms and that selected adapter use fails clearly if `can_motor_control` is absent. -- [x] 5.3 Document adapter-level gravity compensation, upstream OpenArm ROS2 gain presets, and position/effort-only command semantics. -- [x] 5.4 Document staged bring-up: mock/vcan, one-motor validation, full-arm state monitor, gravity compensation, then trajectory-control validation. -- [x] 5.5 Update contributor or coding-agent docs only if implementation introduces a reusable lazy optional binding adapter pattern. - -## 6. Verification - -- [x] 6.1 Run `openspec validate add-dm-motor-arm-adapter`. -- [x] 6.2 Run focused manipulator adapter tests for `dm_motor_arm` and existing `openarm` coverage. -- [x] 6.3 Run focused coordinator tests that cover `ConnectedHardware` state reads and command writes if adapter/coordinator behavior changes. -- [x] 6.4 Run `pytest dimos/robot/test_all_blueprints_generation.py` if blueprint entries or generated registry output change. -- [x] 6.5 Run documentation validation for changed docs, including `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable blocks are added or changed. -- [x] 6.6 Run a mock or vcan DMMotor adapter smoke test through the library/blueprint surface before using real hardware. -- [ ] 6.7 Manually QA the staged hardware procedure on real DMMotor hardware only after mock/vcan validation: one motor enable/read, one motor low-rate hold, full-arm state monitor, adapter gravity compensation, then optional trajectory-control validation. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/.openspec.yaml b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/.openspec.yaml deleted file mode 100644 index b2c3a043ce..0000000000 --- a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: dimos-capability -created: 2026-06-06 diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/design.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/design.md deleted file mode 100644 index 20fe5089d9..0000000000 --- a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/design.md +++ /dev/null @@ -1,122 +0,0 @@ -## Context - -DimOS manipulator hardware is selected through `HardwareComponent.adapter_type`, discovered by the manipulator adapter registry, and consumed by `ControlCoordinator` through the existing `ManipulatorAdapter` protocol. The current branch has two OpenArm-related paths: the existing `openarm` adapter with an in-tree SocketCAN Damiao driver, and a newer binding-backed path currently named `dm_motor_arm` that imports `can_motor_control` lazily and drives an OpenArm-style arm through a Rust-backed Python binding. - -The prior refactor moved `OpenArmAdapter` onto a shared Damiao base. The updated cleanup direction is stricter: keep the original `openarm` adapter source and behavior stable, and isolate the binding-backed path under an explicit OpenArm RS name. Shared Damiao metadata helpers remain useful, but they should not force the original OpenArm adapter to inherit new behavior. - -Relevant surfaces include `dimos/hardware/manipulators/openarm/adapter.py`, `dimos/hardware/manipulators/dm_motor_arm/adapter.py`, `dimos/hardware/manipulators/damiao/specs.py`, `dimos/hardware/manipulators/damiao/base_adapter.py`, `dimos/hardware/manipulators/registry.py`, `dimos/robot/manipulators/openarm/blueprints.py`, `dimos/robot/all_blueprints.py`, and `docs/capabilities/manipulation/openarm_integration.md`. - -## Goals / Non-Goals - -**Goals:** - -- Keep `adapter_type="openarm"` and `OpenArmAdapter` as the stable in-tree OpenArm CAN adapter. -- Restore or preserve the original OpenArm adapter source-level boundary so this cleanup does not change its implementation behavior. -- Rename the binding-backed OpenArm path to `openarm_rs` and expose it as OpenArm-only. -- Keep lazy optional import behavior for the binding-backed path so adapter discovery works without `can_motor_control` installed. -- Keep ControlCoordinator, joint-state streams, joint command streams, task configs, and MCP/skills unchanged. -- Add readable docstrings to Damiao metadata helpers. -- Update docs, tests, and generated blueprint registry entries to match the new names. - -**Non-Goals:** - -- Do not build or vendor the Rust binding in DimOS. -- Do not introduce a broad dynamic YAML/TOML schema for arbitrary Damiao arms. -- Do not make `openarm_rs` a generic Damiao/DMMotor adapter in this cleanup. -- Do not change the public ControlCoordinator stream contract or add new MCP/skill tools. -- Do not perform real hardware validation as part of artifact generation; leave that as staged QA. - -## DimOS Architecture - -External coordinator shape after cleanup: - -```text -ControlCoordinator - -> HardwareComponent(adapter_type="openarm" | "openarm_rs") - -> manipulator adapter registry - -> OpenArmAdapter # original in-tree OpenArm CAN path - -> OpenArmRSAdapter # Rust-backed binding path for OpenArm only -``` - -Recommended code shape: - -```text -dimos/hardware/manipulators/openarm/adapter.py - OpenArmAdapter # standalone/stable source-level OpenArm adapter - -dimos/hardware/manipulators/openarm_rs/adapter.py - OpenArmRSAdapter # renamed binding-backed OpenArm adapter - -dimos/hardware/manipulators/damiao/ - specs.py # typed Damiao metadata and validation helpers - base_adapter.py # shared metadata/limits/control-mode/gravity helpers for adapters that opt in -``` - -The binding-backed adapter should continue to satisfy `ManipulatorAdapter`. It may inherit from `DamiaoArmAdapterBase` if that remains useful for validation, limits, and gravity helpers. The original `OpenArmAdapter` should not be forced through the shared base in this cleanup; preserving its current stable behavior is the compatibility target. - -No new DimOS `Spec` Protocol is required. No module streams, transports, RPC references, skills, or MCP tools change. If runnable blueprint variable names change from `coordinator_dm_motor_openarm*` to `coordinator_openarm_rs*`, regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py`. - -## Decisions - -- **Rename by user-facing role, not motor family.** The adapter is used as an OpenArm binding-backed path, so `openarm_rs` is clearer than `dm_motor_arm`. -- **Keep `openarm` source-level stable.** The existing in-tree OpenArm adapter is the stable path; source-level changes risk hardware behavior drift even if tests pass. -- **Keep optional binding failures selected-adapter scoped.** Import `can_motor_control` only when the `openarm_rs` adapter is selected or connected. -- **Do not preserve unreleased generic configurability.** If the current branch allowed arbitrary non-OpenArm `motor_specs`, remove it or require a future explicit generic Damiao adapter change. -- **Keep shared Damiao helpers modest.** `damiao/specs.py` and `base_adapter.py` should provide metadata validation, limits, control-mode state, and gravity helpers, not own all binding lifecycle details. -- **Treat blueprint renames as public CLI changes.** Runnable names exposed by `dimos list` / `dimos run` need docs and generated registry updates. - -## Safety / Simulation / Replay - -Hardware assumptions: - -- `openarm` remains the stable OpenArm hardware path using the in-tree SocketCAN driver. -- `openarm_rs` requires the Rust-backed `can_motor_control` Python binding from the manipulation extra on supported platforms. -- Binding-backed operation may use CAN-FD and staged mock/vcan validation before real hardware. -- Gravity compensation requires a valid OpenArm model path, joint ordering, signs, and measured state. - -Safety constraints: - -- Do not silently migrate existing OpenArm blueprints from `openarm` to `openarm_rs`. -- Preserve disable-on-disconnect and safe stop behavior for both adapter paths. -- Keep gravity-compensation-only commands free-moving by using zero position stiffness where applicable. -- Keep missing-binding errors explicit and selected-adapter scoped. -- Validate mock/vcan before real hardware; real hardware QA should start with supported one-arm low-rate tests. - -Simulation/replay: - -- Replay is out of scope. -- Existing OpenArm mock/planner blueprints should remain available. -- Binding-backed mock/vcan tests should cover registry discovery, connect, enable, state reads, command writes, stop, and disconnect. - -Manual QA surface: - -- Library surface: discover `openarm` and `openarm_rs` adapter keys. -- CLI surface: `dimos list` includes renamed `openarm_rs` runnable blueprints if names change. -- Binding surface: missing `can_motor_control` produces a clear selected-adapter error. -- Mock/vcan surface: `openarm_rs` can connect, enable, read one coherent state snapshot, write a supported command, stop, and disconnect. - -## Risks / Trade-offs - -- **Reverting OpenArm refactor may duplicate small helpers.** This is acceptable to protect stable hardware behavior; shared helpers can still serve the binding-backed path. -- **Renaming branch-only surfaces may require broad updates.** Mitigate by searching docs, tests, blueprints, registry output, and adapter strings for `dm_motor_arm` and `coordinator-dm-motor-openarm`. -- **`openarm_rs` may imply a specific upstream crate.** Document that the adapter consumes the currently selected `can_motor_control` Python binding and does not vendor Rust crates. -- **Generated registry drift.** Mitigate by running the blueprint generation test whenever runnable blueprint exports change. -- **Hardware safety drift.** Mitigate with focused adapter parity tests and staged mock/vcan before real hardware. - -## Migration / Rollout - -1. Restore or preserve `openarm/adapter.py` as the stable standalone adapter while keeping `adapter_type="openarm"` unchanged. -2. Move or rename `dm_motor_arm` to `openarm_rs`, including class names, package exports, registry key, tests, and missing-binding messages. -3. Narrow the renamed adapter to OpenArm-specific defaults and reject or remove generic non-OpenArm construction paths. -4. Add Damiao metadata docstrings without changing metadata behavior. -5. Rename OpenArm binding-backed blueprints and task names from `dm_motor` wording to `openarm_rs` wording. -6. Update docs and generated registry output if blueprint exports change. -7. Run OpenSpec validation, focused tests, blueprint generation validation, docs validation, and manual QA through registry/CLI/mock surfaces. - -Rollback is straightforward if changes stay isolated: keep the original `openarm` adapter untouched, and revert the `openarm_rs` package/blueprint/docs rename if the binding-backed path needs more design work. - -## Open Questions - -- Should the old `dm_motor_arm` registry key exist as a temporary alias during this unreleased branch, or should it be removed entirely to avoid confusion? -- Should package naming be `openarm_rs` only, or should docs describe it as “OpenArm RS / Rust-backed OpenArm” for readability? -- Should the future generic Damiao adapter be planned as a separate change after OpenArm RS lands? diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/docs.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/docs.md deleted file mode 100644 index cd1ee158c6..0000000000 --- a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/docs.md +++ /dev/null @@ -1,28 +0,0 @@ -## User-Facing Docs - -- Update `docs/capabilities/manipulation/openarm_integration.md` to describe two OpenArm adapter paths: - - `openarm`: stable in-tree SocketCAN OpenArm adapter. - - `openarm_rs`: explicit Rust-backed / `can_motor_control` binding path for OpenArm. -- Replace `dm_motor_arm` and `coordinator-dm-motor-openarm*` user-facing references with `openarm_rs` and the renamed blueprint names. -- Clarify that existing OpenArm blueprints continue to use `adapter_type="openarm"` unless a user explicitly selects the binding-backed path. -- Clarify staged validation for the `openarm_rs` path: binding install, mock/vcan, one-arm state read, low-rate hold/gravity compensation, then trajectory validation. - -## Contributor Docs - -- Update contributor-facing manipulation docs only if they currently recommend `dm_motor_arm` as a generic Damiao extension point. -- If `dimos/hardware/manipulators/README.md` is changed, describe `damiao/specs.py` as typed metadata/validation for Damiao-based adapters and `openarm_rs` as an OpenArm-specific binding-backed adapter. - -## Coding-Agent Docs - -- No AGENTS.md update is expected. -- Update `docs/coding-agents/` only if existing guidance mentions the `dm_motor_arm` adapter key or recommends editing the original `openarm` adapter for binding-backed OpenArm work. - -## Doc Validation - -- Run docs link validation if links or blueprint names are changed in user-facing docs. -- Run `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable code blocks are added or changed. -- If only prose/table names change and no executable blocks are touched, focused review plus repository docs validation is sufficient. - -## No Docs Needed - -Documentation changes are needed because this cleanup renames user-facing adapter and blueprint selection surfaces and clarifies hardware bring-up guidance. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/proposal.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/proposal.md deleted file mode 100644 index affff95f4e..0000000000 --- a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/proposal.md +++ /dev/null @@ -1,40 +0,0 @@ -## Why - -The current Damiao/OpenArm adapter changes have blurred two separate surfaces: the existing `openarm` adapter, which should remain the stable in-tree OpenArm CAN path, and the newer Rust-backed binding path currently named `dm_motor_arm`. The binding-backed path is being used as an OpenArm bring-up adapter, but its generic name and configurable defaults make it look like a general Damiao arm adapter. - -This cleanup matters now because the branch is close to landing and hardware-facing adapter naming, safety behavior, and compatibility expectations need to be clear before implementation accumulates more call sites. The goal is to keep existing OpenArm users on the unchanged `openarm` adapter while making the Rust-backed OpenArm path explicit and easier to reason about. - -## What Changes - -- Rename and scope the Rust-backed OpenArm adapter path from the generic `dm_motor_arm` surface to an explicit `openarm_rs` surface. -- Preserve the existing `openarm` adapter as the stable OpenArm adapter and avoid source-level changes to its behavior in this cleanup. -- Keep shared Damiao metadata and validation helpers under `dimos/hardware/manipulators/damiao/` for the Rust-backed OpenArm path and future Damiao adapters. -- Add clearer class/function docstrings to Damiao metadata helpers so motor specs, arm specs, validation, and recv-id defaults are understandable. -- Update OpenArm blueprints, docs, tests, and generated registry entries that currently expose the `dm_motor_arm` OpenArm path. -- **BREAKING** for unreleased branch-only usage: the binding-backed OpenArm adapter key and blueprint names should use `openarm_rs` instead of `dm_motor_arm` / `coordinator-dm-motor-openarm`. - -## Affected DimOS Surfaces - -- Modules/streams: manipulator adapter implementations and tests; no changes to ControlCoordinator streams or joint command/state message contracts. -- Blueprints/CLI: OpenArm blueprint exports and `dimos run` blueprint names for the binding-backed OpenArm path; adapter registry key for the binding-backed path. -- Skills/MCP: no skill or MCP tool changes. -- Hardware/simulation/replay: OpenArm hardware adapter selection and binding-backed mock/vcan bring-up path; no replay behavior changes. -- Docs/generated registries: OpenArm integration docs, possible manipulator contributor docs, and `dimos/robot/all_blueprints.py` if runnable blueprint names change. - -## Capabilities - -### New Capabilities - -- `openarm-rs-adapter-selection`: User-visible selection, naming, and safety expectations for the Rust-backed OpenArm adapter path. -- `damiao-adapter-metadata-docs`: Developer-visible readability and documentation expectations for Damiao arm metadata helpers. - -### Modified Capabilities - -- `openarm-adapter-compatibility`: Preserve the existing `openarm` adapter path while adding the explicit `openarm_rs` path. -- `dm-motor-manipulator-adapters`: Rename and narrow the current binding-backed OpenArm behavior so it is not presented as a generic Damiao/DMMotor adapter. - -## Impact - -Existing `openarm` users should see no behavior change: adapter selection, constructor arguments, blueprints, safety behavior, and in-tree CAN driver operation remain stable. Developers using the branch-only `dm_motor_arm` OpenArm path will need to switch to `openarm_rs` names before implementation or docs are considered complete. - -Compatibility risk is concentrated around registry discovery, blueprint generation, and documentation drift. Testing should cover adapter registry keys, OpenArm compatibility tests, Rust-backed OpenArm mock/vcan behavior, focused Damiao metadata validation, OpenSpec validation, blueprint registry generation if names change, and manual QA through the library or `dimos run` surfaces. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/damiao-adapter-metadata-docs/spec.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/damiao-adapter-metadata-docs/spec.md deleted file mode 100644 index 717f4f7a11..0000000000 --- a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/damiao-adapter-metadata-docs/spec.md +++ /dev/null @@ -1,27 +0,0 @@ -## ADDED Requirements - -### Requirement: Readable Damiao metadata documentation - -DimOS SHALL provide readable developer documentation for Damiao adapter metadata helpers so future maintainers can understand motor order, identifiers, limits, gains, and validation behavior. - -#### Scenario: Reading motor metadata helpers -- **GIVEN** a developer opens the Damiao metadata helper module -- **WHEN** they inspect the motor metadata type and receive-ID behavior -- **THEN** the documentation SHALL explain the meaning of joint order, motor type, send ID, receive ID, and default receive-ID derivation -- **AND** it MUST make clear that the metadata is used before hardware commands are sent. - -#### Scenario: Reading arm metadata helpers -- **GIVEN** a developer opens the Damiao arm metadata type -- **WHEN** they inspect arm limits, gains, gravity model fields, bus fields, or validation helpers -- **THEN** the documentation SHALL explain that these values describe explicit adapter-owned hardware metadata -- **AND** it SHALL distinguish metadata coercion and validation from runtime hardware communication. - -### Requirement: Damiao metadata validation readability - -DimOS SHALL keep Damiao metadata validation behavior understandable without requiring readers to infer intent from tests alone. - -#### Scenario: Understanding validation failures -- **GIVEN** an adapter provides duplicate IDs or mismatched vector lengths -- **WHEN** validation rejects the metadata -- **THEN** the helper documentation SHALL make the validation intent clear to developers -- **AND** validation errors MUST remain focused on preventing malformed metadata from reaching hardware command paths. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/dm-motor-manipulator-adapters/spec.md deleted file mode 100644 index e7e8cc1b23..0000000000 --- a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/dm-motor-manipulator-adapters/spec.md +++ /dev/null @@ -1,39 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Binding-backed OpenArm adapter scope - -DimOS SHALL narrow the current binding-backed DMMotor/OpenArm behavior into an explicit OpenArm RS adapter path rather than presenting it as a generic DMMotor arm adapter. - -#### Scenario: Selecting the renamed binding-backed adapter -- **GIVEN** a hardware configuration needs the Rust-backed OpenArm binding path -- **WHEN** the configuration selects an adapter type -- **THEN** it SHALL use `openarm_rs` for the binding-backed OpenArm adapter -- **AND** it SHALL NOT rely on `dm_motor_arm` as the documented OpenArm binding-backed adapter key. - -#### Scenario: Avoiding generic Damiao defaults -- **GIVEN** a non-OpenArm Damiao arm needs support in the future -- **WHEN** a developer evaluates the OpenArm RS adapter -- **THEN** DimOS SHALL make clear that OpenArm RS metadata and defaults are OpenArm-specific -- **AND** DimOS MUST require a separate explicit adapter or change before treating those defaults as generic Damiao behavior. - -### Requirement: Binding-backed adapter safety behavior - -DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. - -#### Scenario: Coherent state reads -- **GIVEN** the OpenArm RS adapter is connected through the binding-backed path -- **WHEN** DimOS reads positions, velocities, and efforts during one coordinator cycle -- **THEN** the adapter SHALL provide a coherent state snapshot rather than independently loading the CAN bus for each read -- **AND** stale or malformed state SHALL be surfaced before unsafe commands are sent. - -#### Scenario: Gravity-compensation-only command -- **GIVEN** a user invokes a gravity-compensation-only validation path through the binding-backed adapter -- **WHEN** the adapter sends MIT commands for gravity compensation -- **THEN** the command SHALL use zero position stiffness so the arm remains manually movable -- **AND** the command SHALL use current measured state and configured OpenArm gravity metadata. - -#### Scenario: Missing binding remains selected-adapter scoped -- **GIVEN** the optional binding is not installed -- **WHEN** DimOS discovers manipulator adapters without selecting OpenArm RS -- **THEN** discovery SHALL continue for adapters that do not require the binding -- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-adapter-compatibility/spec.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-adapter-compatibility/spec.md deleted file mode 100644 index 33a37247c5..0000000000 --- a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-adapter-compatibility/spec.md +++ /dev/null @@ -1,39 +0,0 @@ -## MODIFIED Requirements - -### Requirement: OpenArm adapter compatibility - -DimOS SHALL preserve the existing OpenArm adapter selection and observable behavior while keeping the binding-backed OpenArm RS path separate. - -#### Scenario: Selecting the OpenArm adapter -- **GIVEN** an existing hardware configuration selects the OpenArm adapter -- **WHEN** DimOS initializes the manipulator hardware -- **THEN** DimOS SHALL continue to create an OpenArm-specific adapter through the existing `openarm` adapter selection surface -- **AND** the adapter SHALL preserve OpenArm-specific side, joint, motor, limit, gain, gravity-model, and in-tree CAN-driver behavior. - -#### Scenario: Existing OpenArm blueprints -- **GIVEN** an existing OpenArm blueprint selects the current OpenArm adapter path -- **WHEN** this cleanup is present -- **THEN** the blueprint SHALL continue selecting the `openarm` adapter unless it is explicitly changed -- **AND** existing stable OpenArm runnable blueprint names SHALL remain stable unless a generated-registry update intentionally changes only binding-backed OpenArm RS names. - -#### Scenario: OpenArm source-level stability -- **GIVEN** the original OpenArm adapter is the stable hardware path -- **WHEN** the binding-backed OpenArm RS path is renamed or refactored -- **THEN** DimOS SHALL NOT require the original OpenArm adapter to inherit binding-backed or shared Damiao runtime behavior -- **AND** any source-level OpenArm adapter changes MUST be limited to preserving existing behavior or undoing unintended refactor drift. - -### Requirement: OpenArm hardware safety preservation - -OpenArm adapter behavior SHALL remain at least as safe as the pre-cleanup behavior for enablement, command writes, gravity compensation, and shutdown. - -#### Scenario: Stopping or disconnecting OpenArm hardware -- **GIVEN** OpenArm motors are enabled through DimOS -- **WHEN** the adapter is stopped or disconnected -- **THEN** DimOS SHALL attempt to disable or stop commanding the motors through the adapter -- **AND** the cleanup SHALL NOT introduce a continued background command loop after disconnect. - -#### Scenario: OpenArm gravity compensation -- **GIVEN** OpenArm gravity compensation is enabled -- **WHEN** DimOS computes and sends supported OpenArm commands -- **THEN** gravity feed-forward SHALL use the OpenArm-specific model and current measured joint state -- **AND** invalid or stale state SHALL prevent unsafe gravity-compensation commands from being sent. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-rs-adapter-selection/spec.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-rs-adapter-selection/spec.md deleted file mode 100644 index aa2e7d8b4b..0000000000 --- a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/specs/openarm-rs-adapter-selection/spec.md +++ /dev/null @@ -1,37 +0,0 @@ -## ADDED Requirements - -### Requirement: Explicit OpenArm RS adapter selection - -DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. - -#### Scenario: Selecting the Rust-backed OpenArm path -- **GIVEN** a hardware configuration selects the binding-backed OpenArm adapter -- **WHEN** DimOS initializes manipulator hardware through the adapter registry -- **THEN** the configuration SHALL select the adapter using the `openarm_rs` key -- **AND** the selected adapter SHALL represent OpenArm hardware rather than a generic Damiao arm. - -#### Scenario: Binding unavailable for OpenArm RS -- **GIVEN** the `can_motor_control` binding is not importable in the runtime environment -- **WHEN** a user selects the `openarm_rs` adapter -- **THEN** DimOS SHALL fail with a clear selected-adapter missing-binding error -- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding. - -### Requirement: OpenArm RS blueprint naming - -DimOS SHALL expose binding-backed OpenArm runnable blueprints with names that identify the OpenArm RS path. - -#### Scenario: Listing binding-backed OpenArm blueprints -- **GIVEN** binding-backed OpenArm blueprints are exported -- **WHEN** a user lists or runs OpenArm blueprints through the DimOS CLI -- **THEN** runnable names SHALL use `openarm-rs` wording instead of `dm-motor-openarm` wording -- **AND** documentation SHALL describe those blueprints as opt-in alternatives to the stable `openarm` path. - -### Requirement: OpenArm RS safety staging - -DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. - -#### Scenario: Validating before real hardware operation -- **GIVEN** a user wants to run OpenArm hardware through the `openarm_rs` path -- **WHEN** they follow DimOS bring-up guidance -- **THEN** the guidance SHALL start with binding availability and mock or virtual CAN validation -- **AND** it SHALL treat real hardware gravity compensation and trajectory execution as later staged QA steps. diff --git a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/tasks.md b/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/tasks.md deleted file mode 100644 index 6a3396826b..0000000000 --- a/openspec/changes/archive/2026-06-06-cleanup-openarm-rs-adapter/tasks.md +++ /dev/null @@ -1,35 +0,0 @@ -## 1. Adapter Boundary Cleanup - -- [x] 1.1 Restore or preserve `dimos/hardware/manipulators/openarm/adapter.py` as the stable standalone OpenArm adapter with `adapter_type="openarm"` unchanged. -- [x] 1.2 Create `dimos/hardware/manipulators/openarm_rs/` from the binding-backed adapter path and export an OpenArm-specific adapter class. -- [x] 1.3 Register the binding-backed adapter under `openarm_rs` and update selected-adapter missing-binding messages to reference `adapter_type="openarm_rs"`. -- [x] 1.4 Remove or reject generic non-OpenArm construction paths from the renamed binding-backed adapter so OpenArm defaults are not treated as generic Damiao defaults. -- [x] 1.5 Keep lazy `can_motor_control` imports scoped to OpenArm RS selection or connection so unrelated adapter discovery remains healthy. - -## 2. Damiao Metadata Readability - -- [x] 2.1 Add class and function docstrings to `dimos/hardware/manipulators/damiao/specs.py` explaining motor metadata, receive-ID defaulting, arm metadata, validation, and coercion behavior. -- [x] 2.2 Confirm Damiao metadata validation behavior remains unchanged after docstring-only edits. - -## 3. Blueprints, Registry, and Tests - -- [x] 3.1 Rename binding-backed OpenArm blueprint variables and runnable names from `dm_motor_openarm` / `dm-motor-openarm` wording to `openarm_rs` / `openarm-rs` wording. -- [x] 3.2 Update OpenArm RS tests to assert the `openarm_rs` registry key, class/package names, missing-binding message, OpenArm-only scope, and mock/vcan lifecycle behavior. -- [x] 3.3 Update OpenArm adapter compatibility tests to prove the stable `openarm` key and observable OpenArm behavior remain unchanged. -- [x] 3.4 Regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py` if runnable blueprint exports change. - -## 4. Documentation - -- [x] 4.1 Update `docs/capabilities/manipulation/openarm_integration.md` to describe `openarm` and `openarm_rs` adapter paths and staged OpenArm RS validation. -- [x] 4.2 Replace user-facing `dm_motor_arm` / `coordinator-dm-motor-openarm*` references with `openarm_rs` / renamed blueprint references where this cleanup changes the surface. -- [x] 4.3 Update contributor or coding-agent docs only if they mention the old binding-backed adapter key or recommend editing the original OpenArm adapter for OpenArm RS work. - -## 5. Verification - -- [x] 5.1 Run `openspec validate cleanup-openarm-rs-adapter`. -- [x] 5.2 Run focused tests for `dimos/hardware/manipulators/openarm/`, `dimos/hardware/manipulators/openarm_rs/`, and `dimos/hardware/manipulators/damiao/`. -- [x] 5.3 Run `pytest dimos/robot/test_all_blueprints_generation.py` when blueprint names or exports change, and keep `dimos/robot/all_blueprints.py` current. -- [x] 5.4 Run docs validation for changed docs, including `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable blocks are added or changed. -- [x] 5.5 Manually QA adapter registry discovery through the library surface by confirming `openarm` and `openarm_rs` keys are available and `dm_motor_arm` is absent or intentionally handled. -- [x] 5.6 Manually QA OpenArm RS mock/vcan operation through the library or coordinator surface: connect, enable, read a coherent state snapshot, write a supported command, stop, and disconnect. -- [ ] 5.7 Manually QA real OpenArm RS hardware only after mock/vcan validation: one-arm enable/read, low-rate hold or gravity compensation, safe stop, and optional trajectory-control validation. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/.openspec.yaml b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/.openspec.yaml deleted file mode 100644 index 50d33507f6..0000000000 --- a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: dimos-capability -created: 2026-06-07 diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/design.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/design.md deleted file mode 100644 index 267d171ab7..0000000000 --- a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/design.md +++ /dev/null @@ -1,112 +0,0 @@ -## Context - -`dimos.hardware.manipulators.registry.AdapterRegistry` currently discovers adapters by scanning `dimos/hardware/manipulators/` and importing each `/adapter.py` module during registry import. That makes a simple `from dimos.hardware.manipulators.registry import adapter_registry` execute implementation modules for `xarm`, `piper`, `openarm_rs`, `sim_mujoco`, and other adapters before a user has selected an adapter. - -This conflicts with existing requirements for optional hardware bindings. `openarm_rs` must remain discoverable when `can_motor_control` is not installed, and missing binding errors must be scoped to selecting or connecting `openarm_rs`. The current Damiao/OpenArm RS base compensates by keeping binding imports lazy and typing them through local Protocols. That preserves behavior but makes implementation code harder to read and maintain. - -The control-task registry already solves the same class of problem. `dimos/control/tasks/registry.py` imports lightweight `__registry__.py` manifests containing string import paths, and imports the real task implementation only when the selected task is created. - -## Goals / Non-Goals - -**Goals:** - -- Make manipulator adapter discovery metadata-only: listing adapters must not import unselected adapter implementation modules. -- Preserve existing adapter keys and the `adapter_registry.available()` / `adapter_registry.create(name, **kwargs)` API. -- Keep optional dependency failures selected-adapter scoped, especially for `openarm_rs` and future Damiao-backed adapters. -- Establish a contributor pattern for adding manipulator adapters through lightweight manifests. -- Enable follow-up simplification of Damiao/OpenArm RS implementation imports where normal selected-path imports are clearer. - -**Non-Goals:** - -- Do not change stream contracts, coordinator task behavior, blueprint names, or MCP/skill exposure. -- Do not migrate drive-train or whole-body registries in this change, though they may benefit from the same pattern later. -- Do not introduce setuptools entry points or third-party plugin packaging for in-tree adapters now. -- Do not make `openarm_rs` a generic Damiao adapter or change its hardware safety semantics. - -## DimOS Architecture - -The affected runtime path is the manipulator adapter selection path used by `ControlCoordinator._create_adapter()`: - -```text -HardwareComponent(adapter_type=...) - -> ControlCoordinator._create_adapter() - -> adapter_registry.create(adapter_type, dof=..., address=..., hardware_id=..., **kwargs) - -> selected manipulator adapter instance -``` - -No `In[T]`/`Out[T]` stream names or transport choices change. The `ManipulatorAdapter` Protocol remains the adapter surface. No new DimOS `Spec` Protocol, RPC contract, skill, or MCP tool is required. - -The registry should mirror the control-task lazy registry structure: - -```text -dimos/hardware/manipulators//__registry__.py - ADAPTER_FACTORIES = { - "adapter_key": "dimos.hardware.manipulators..adapter:AdapterClass" - } - -dimos/hardware/manipulators/registry.py - discover() imports only __registry__.py manifests - available() returns manifest keys - create(name, **kwargs) resolves the selected import path and instantiates it -``` - -The registry should cache resolved factories/classes after the first selected import, like `ControlTaskRegistry._factories`, so repeated `create()` calls for the same adapter do not repeatedly import the module. - -## Decisions - -1. **Use per-adapter `__registry__.py` manifests for built-in adapters.** - - Rationale: This is the closest in-repo precedent and works for source-tree adapters without packaging entry points. - - Alternative considered: Python package entry points. They are a good long-term extension point for third-party adapter packages, but they add packaging complexity and are unnecessary for in-tree adapters. - -2. **Store adapter factories as `"module:attr"` strings.** - - Rationale: Strings are safe to read during discovery and keep implementation modules unimported until selected. - - The registry should validate the colon format, duplicate adapter keys, and non-string mappings when reading manifests. - -3. **Preserve `register(name, cls)` for direct tests/manual registration, but make discovery use `register_path()`.** - - Rationale: Existing adapter unit tests call `register(registry)` directly and external code may construct an `AdapterRegistry` for tests. Keeping direct registration avoids unnecessary churn. - - Discovery should not call old adapter-level `register()` functions because doing so requires importing implementation modules. - -4. **Fail clearly on selected adapter load errors.** - - Unknown adapter names should list `available()` keys. - - Missing implementation module or attribute should identify the selected adapter key and import path. - - Optional dependency errors should remain adapter-specific. `openarm_rs` should keep its current clear missing-binding error when the selected path reaches its binding import. - -5. **Update all built-in manipulator adapters in one migration.** - - Rationale: Partial migration could make `available()` omit existing adapters or keep eager imports for some adapters. Each existing adapter package should get a manifest preserving its current key. - -## Safety / Simulation / Replay - -This change should not alter hardware commands or simulation behavior. It only changes when adapter implementation modules are imported. - -Safety-relevant expectations: - -- Listing adapters must be safe in environments without hardware SDKs or platform-gated extras. -- Selecting an adapter that needs a missing SDK must fail before unsafe hardware commands are issued. -- `openarm_rs` must preserve its staged validation path: binding availability, mock/virtual CAN validation, then real hardware gravity/trajectory validation. -- `sim_mujoco` should remain selectable by key without causing simulation imports during unrelated adapter listing. - -Manual QA should use the library surface: import the registry, list adapters, create a mock adapter, and exercise selected-adapter missing-binding behavior for an optional adapter in a controlled test environment. - -## Risks / Trade-offs - -- **Manifest drift:** A manifest path can point at a missing module or attribute. Mitigate with focused registry tests that resolve every built-in adapter factory in an environment with project extras available, plus clear selected-path errors. -- **Duplicate keys:** Multiple manifests can claim the same adapter key. Mitigate by rejecting conflicting registrations during discovery. -- **Behavioral timing shift:** Some import-time errors move from registry import to `create()` or adapter connect. This is intentional for optional dependencies, but selected-path errors must remain actionable. -- **Docs mismatch:** Existing docs describe `adapter.py` auto-discovery. Update adapter authoring docs so new adapters add a manifest. -- **Scope creep:** Drive-train and whole-body registries use similar eager patterns, but migrating them is out of scope for this change. - -## Migration / Rollout - -1. Add lazy path registration support to `AdapterRegistry` while preserving direct class registration for tests. -2. Change manipulator discovery to import `.__registry__` manifests instead of `.adapter` modules. -3. Add `__registry__.py` manifests for each existing built-in manipulator adapter key. -4. Update tests to assert that registry import/listing does not import adapter implementation modules and that `create()` imports only the selected adapter. -5. Update OpenArm RS and Damiao binding tests to preserve selected-adapter-scoped missing-binding behavior. -6. Update manipulator adapter authoring documentation. - -No generated blueprint registry update is expected. Rollback is straightforward: restore eager adapter module discovery and remove manifests, but that would also restore registry-time optional import pressure. - -## Open Questions - -- Should external third-party manipulator adapters eventually use Python entry points in addition to in-tree manifests? -- Should drive-train and whole-body registries be migrated to the same manifest pattern in a follow-up change? diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/docs.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/docs.md deleted file mode 100644 index 298e84c0fc..0000000000 --- a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/docs.md +++ /dev/null @@ -1,28 +0,0 @@ -## User-Facing Docs - -- Update `dimos/hardware/manipulators/README.md` to describe manifest-based adapter discovery instead of `adapter.py` auto-discovery. -- Update `docs/capabilities/manipulation/adding_a_custom_arm.md` so custom adapter instructions include: - - creating `/__registry__.py`, - - mapping adapter keys to `"module:ClassOrFactory"` import paths, - - keeping optional hardware SDK failures scoped to selected adapter creation/connect, - - verifying `adapter_registry.available()` and `adapter_registry.create()`. -- Keep `docs/capabilities/manipulation/openarm_integration.md` wording that `openarm_rs` remains opt-in and missing `can_motor_control` fails only when selected. Update only if implementation changes the exact failure timing or wording. - -## Contributor Docs - -- No broad `docs/development/` update is required unless implementation introduces a new registry-generation or validation command. -- If registry tests add a dedicated contributor workflow for adapter manifests, document it near the manipulator docs rather than in general development docs. - -## Coding-Agent Docs - -- No `AGENTS.md` update is required for this change. -- Consider updating `docs/coding-agents/` only if there is an existing manipulator adapter authoring guide for coding agents; otherwise the user-facing custom-arm guide is sufficient. - -## Doc Validation - -- Run markdown/doc validation used by this repo for changed docs, such as `doclinks` if available. -- For changed Python snippets in `docs/capabilities/manipulation/adding_a_custom_arm.md`, run the repository's markdown Python snippet validator if configured, or manually inspect fenced snippets for import path accuracy. - -## No Docs Needed - -Documentation changes are needed because existing custom-arm docs currently teach `adapter.py` auto-discovery, which would become stale after manifest-based discovery. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/proposal.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/proposal.md deleted file mode 100644 index f52b197bfe..0000000000 --- a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/proposal.md +++ /dev/null @@ -1,37 +0,0 @@ -## Why - -The manipulator adapter registry currently discovers adapters by importing every `dimos.hardware.manipulators..adapter` module at registry import time. That makes discovery depend on every adapter module being safe to import in every environment, even when a user only wants to list adapters or instantiate an unrelated adapter. - -This is especially painful for optional hardware bindings such as the Rust-backed Damiao/OpenArm RS path: adapter implementation files must avoid normal imports and carry extra lazy-import/protocol/cast boilerplate so registry discovery does not fail or trigger heavy SDK side effects. DimOS already has a cleaner precedent in the control task registry: lightweight manifest modules advertise available names, while implementation imports happen only for the selected type. - -## What Changes - -- Modify manipulator adapter discovery to import lightweight per-adapter registry manifests instead of importing every adapter implementation module. -- Register adapter names to lazy factory import paths and resolve exactly one adapter implementation when `adapter_registry.create(name, **kwargs)` is called. -- Preserve the existing public `adapter_registry.available()` and `adapter_registry.create()` library surface. -- Preserve selected-adapter-scoped optional dependency failures: missing hardware bindings should not break discovery or unrelated adapters, and should fail clearly when the affected adapter is selected. -- Add compatibility support for existing direct `register(registry)` adapter modules during migration only if it does not reintroduce eager implementation imports. -- No **BREAKING** public API or CLI behavior is intended. - -## Affected DimOS Surfaces - -- Modules/streams: No stream contracts change. Control coordinator manipulator adapter creation continues through `HardwareComponent.adapter_type` and `adapter_registry.create()`. -- Blueprints/CLI: Existing blueprints and CLI runs that select manipulator adapters should keep the same adapter keys. -- Skills/MCP: No direct MCP or skill surface change. -- Hardware/simulation/replay: Manipulator hardware adapters, including `openarm`, `openarm_rs`, `xarm`, `piper`, `a750`, `mock`, and `sim_mujoco`, remain selectable by the same names. Optional SDK failures remain scoped to selected adapters. -- Docs/generated registries: Update manipulator adapter authoring docs to describe manifest-based lazy discovery. No generated blueprint registry changes are expected. - -## Capabilities - -### New Capabilities -- `manipulator-adapter-discovery`: Behavior for discovering, listing, and instantiating manipulator adapters without importing unselected adapter implementations. - -### Modified Capabilities -- `openarm-rs-adapter-selection`: Preserve and clarify the requirement that missing `can_motor_control` fails only when `openarm_rs` is selected, not during registry discovery. -- `dm-motor-manipulator-adapters`: Preserve and clarify selected-adapter-scoped binding availability for Damiao/DMMotor-backed manipulator adapters. - -## Impact - -Users should see the same adapter names and selection behavior, but adapter listing/import becomes more reliable in partial installations. Developers can write adapter implementation modules with normal imports appropriate to selected-adapter runtime paths instead of keeping all implementation modules discovery-safe. - -Compatibility risk is concentrated in registry migration: existing adapter keys must remain stable, duplicate manifests must be rejected clearly, and missing manifest or malformed import paths must produce actionable errors. Test coverage should prove that `available()` does not import adapter implementations, `create()` imports only the requested implementation, and missing optional bindings do not break unrelated adapters. Manual QA should exercise the library surface by listing adapters and creating at least mock plus one lazy-registered adapter path. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/dm-motor-manipulator-adapters/spec.md deleted file mode 100644 index 7b03b5b164..0000000000 --- a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/dm-motor-manipulator-adapters/spec.md +++ /dev/null @@ -1,40 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Binding-backed OpenArm adapter scope - -DimOS SHALL narrow the current binding-backed DMMotor/OpenArm behavior into an explicit OpenArm RS adapter path rather than presenting it as a generic DMMotor arm adapter. - -#### Scenario: Selecting the renamed binding-backed adapter -- **GIVEN** a hardware configuration needs the Rust-backed OpenArm binding path -- **WHEN** the configuration selects an adapter type -- **THEN** it SHALL use `openarm_rs` for the binding-backed OpenArm adapter -- **AND** it SHALL NOT rely on `dm_motor_arm` as the documented OpenArm binding-backed adapter key. - -#### Scenario: Avoiding generic Damiao defaults -- **GIVEN** a non-OpenArm Damiao arm needs support in the future -- **WHEN** a developer evaluates the OpenArm RS adapter -- **THEN** DimOS SHALL make clear that OpenArm RS metadata and defaults are OpenArm-specific -- **AND** DimOS MUST require a separate explicit adapter or change before treating those defaults as generic Damiao behavior. - -### Requirement: Binding-backed adapter safety behavior - -DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. - -#### Scenario: Coherent state reads -- **GIVEN** the OpenArm RS adapter is connected through the binding-backed path -- **WHEN** DimOS reads positions, velocities, and efforts during one coordinator cycle -- **THEN** the adapter SHALL provide a coherent state snapshot rather than independently loading the CAN bus for each read -- **AND** stale or malformed state SHALL be surfaced before unsafe commands are sent. - -#### Scenario: Gravity-compensation-only command -- **GIVEN** a user invokes a gravity-compensation-only validation path through the binding-backed adapter -- **WHEN** the adapter sends MIT commands for gravity compensation -- **THEN** the command SHALL use zero position stiffness so the arm remains manually movable -- **AND** the command SHALL use current measured state and configured OpenArm gravity metadata. - -#### Scenario: Missing binding remains selected-adapter scoped -- **GIVEN** the optional binding is not installed -- **WHEN** DimOS discovers or lists manipulator adapters without selecting OpenArm RS -- **THEN** discovery SHALL continue for adapters that do not require the binding -- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path -- **AND** registry listing MUST NOT import the OpenArm RS implementation only to discover its adapter key. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/manipulator-adapter-discovery/spec.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/manipulator-adapter-discovery/spec.md deleted file mode 100644 index 18277bae5d..0000000000 --- a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/manipulator-adapter-discovery/spec.md +++ /dev/null @@ -1,49 +0,0 @@ -## ADDED Requirements - -### Requirement: Metadata-only manipulator adapter listing - -DimOS SHALL list registered manipulator adapter keys without importing unselected adapter implementation modules. - -#### Scenario: Listing adapters in a partial installation -- **GIVEN** a DimOS environment where one or more optional manipulator hardware SDKs are not installed -- **WHEN** a user imports the manipulator adapter registry and asks for available adapter keys -- **THEN** DimOS SHALL return the known adapter keys whose lightweight registry metadata is available -- **AND** DimOS MUST NOT import unselected adapter implementation modules only to produce that list. - -#### Scenario: Unrelated adapter remains discoverable -- **GIVEN** an optional binding required by one manipulator adapter is not importable -- **WHEN** a user lists manipulator adapters without selecting that adapter -- **THEN** DimOS SHALL continue discovering unrelated manipulator adapters -- **AND** missing optional dependency errors SHALL NOT prevent unrelated adapter keys from appearing. - -### Requirement: Selected manipulator adapter loading - -DimOS SHALL import and instantiate only the manipulator adapter selected by `adapter_registry.create(name, **kwargs)`. - -#### Scenario: Creating a selected adapter -- **GIVEN** a registered manipulator adapter key and constructor arguments for that adapter -- **WHEN** a user calls `adapter_registry.create()` with that key -- **THEN** DimOS SHALL resolve the selected adapter implementation -- **AND** DimOS SHALL instantiate the selected adapter with the provided arguments. - -#### Scenario: Unknown adapter name -- **GIVEN** a manipulator adapter key is not registered -- **WHEN** a user calls `adapter_registry.create()` with that key -- **THEN** DimOS SHALL fail with an error that identifies the unknown adapter -- **AND** the error SHALL include the currently available adapter keys. - -#### Scenario: Broken selected adapter registration -- **GIVEN** a manipulator adapter key is registered to an implementation path that cannot be resolved -- **WHEN** a user selects that adapter key -- **THEN** DimOS SHALL fail with an actionable selected-adapter error -- **AND** the error SHALL identify the selected adapter key or its configured implementation path. - -### Requirement: Stable manipulator adapter keys - -DimOS SHALL preserve existing built-in manipulator adapter keys across the lazy discovery migration. - -#### Scenario: Existing adapter keys remain available -- **GIVEN** a user has a hardware configuration or blueprint that selects an existing built-in manipulator adapter key -- **WHEN** DimOS discovers manipulator adapters after this change -- **THEN** that key SHALL remain available if its lightweight registry metadata is present -- **AND** users SHALL NOT need to rename existing manipulator adapter selections for this registry migration. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/openarm-rs-adapter-selection/spec.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/openarm-rs-adapter-selection/spec.md deleted file mode 100644 index 932eb0c84a..0000000000 --- a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/specs/openarm-rs-adapter-selection/spec.md +++ /dev/null @@ -1,38 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Explicit OpenArm RS adapter selection - -DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. - -#### Scenario: Selecting the Rust-backed OpenArm path -- **GIVEN** a hardware configuration selects the binding-backed OpenArm adapter -- **WHEN** DimOS initializes manipulator hardware through the adapter registry -- **THEN** the configuration SHALL select the adapter using the `openarm_rs` key -- **AND** the selected adapter SHALL represent OpenArm hardware rather than a generic Damiao arm. - -#### Scenario: Binding unavailable for OpenArm RS -- **GIVEN** the `can_motor_control` binding is not importable in the runtime environment -- **WHEN** a user selects the `openarm_rs` adapter -- **THEN** DimOS SHALL fail with a clear selected-adapter missing-binding error -- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding -- **AND** listing manipulator adapters MUST NOT import the OpenArm RS implementation only to discover its adapter key. - -### Requirement: OpenArm RS blueprint naming - -DimOS SHALL expose binding-backed OpenArm runnable blueprints with names that identify the OpenArm RS path. - -#### Scenario: Listing binding-backed OpenArm blueprints -- **GIVEN** binding-backed OpenArm blueprints are exported -- **WHEN** a user lists or runs OpenArm blueprints through the DimOS CLI -- **THEN** runnable names SHALL use `openarm-rs` wording instead of `dm-motor-openarm` wording -- **AND** documentation SHALL describe those blueprints as opt-in alternatives to the stable `openarm` path. - -### Requirement: OpenArm RS safety staging - -DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. - -#### Scenario: Validating before real hardware operation -- **GIVEN** a user wants to run OpenArm hardware through the `openarm_rs` path -- **WHEN** they follow DimOS bring-up guidance -- **THEN** the guidance SHALL start with binding availability and mock or virtual CAN validation -- **AND** it SHALL treat real hardware gravity compensation and trajectory execution as later staged QA steps. diff --git a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/tasks.md b/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/tasks.md deleted file mode 100644 index 04968f8e0a..0000000000 --- a/openspec/changes/archive/2026-06-06-lazy-manipulator-adapter-registry/tasks.md +++ /dev/null @@ -1,40 +0,0 @@ -## 1. Registry Implementation - -- [x] 1.1 Update `dimos/hardware/manipulators/registry.py` to store lazy adapter factory import paths alongside directly registered adapter classes. -- [x] 1.2 Add a `register_path(name, factory_path)`-style API with `module:attribute` validation, duplicate-key detection, and actionable errors for malformed paths. -- [x] 1.3 Change manipulator discovery to import `.__registry__` manifests and read an `ADAPTER_FACTORIES` string mapping instead of importing `.adapter` implementation modules. -- [x] 1.4 Resolve and cache the selected adapter implementation only inside `adapter_registry.create(name, **kwargs)`. -- [x] 1.5 Preserve existing `register(name, cls)` behavior for direct programmatic registration and existing adapter-level unit tests. -- [x] 1.6 Add lightweight `__registry__.py` manifests for built-in adapters: `a750`, `mock`, `openarm`, `openarm_rs`, `piper`, `sim`, and `xarm`. -- [x] 1.7 Keep current adapter keys unchanged: `a750`, `mock`, `openarm`, `openarm_rs`, `piper`, `sim_mujoco`, and `xarm`. - -## 2. Adapter Cleanup Boundaries - -- [x] 2.1 Review `dimos/hardware/manipulators/damiao/base_adapter.py` after lazy registry discovery is in place and simplify only import scaffolding that no longer protects registry listing. -- [x] 2.2 Preserve OpenArm RS selected-adapter missing-binding error behavior and install guidance for absent `can_motor_control`. -- [x] 2.3 Avoid changing hardware command semantics, gravity compensation behavior, state caching, or adapter constructor contracts while refactoring discovery. - -## 3. Tests - -- [x] 3.1 Add focused manipulator registry tests proving `adapter_registry.available()` does not import adapter implementation modules. -- [x] 3.2 Add tests proving `adapter_registry.create()` imports only the selected adapter implementation and passes constructor kwargs through. -- [x] 3.3 Add tests for malformed manifest entries, duplicate adapter keys, unknown adapter names, and missing selected implementation paths. -- [x] 3.4 Add or update tests proving registry discovery/listing remains healthy when `can_motor_control` is not importable. -- [x] 3.5 Update OpenArm RS/Damiao tests to preserve normal selected-path import style and clear missing-binding failure behavior. -- [x] 3.6 Add a regression test that expected built-in manipulator adapter keys remain available after manifest migration. - -## 4. Documentation - -- [x] 4.1 Update `docs/capabilities/manipulation/adding_a_custom_arm.md` to teach `__registry__.py` manifest-based discovery and lazy selected adapter imports. -- [x] 4.2 Update `dimos/hardware/manipulators/README.md` if its adapter structure or adding-a-new-arm section still describes eager `adapter.py` discovery. -- [x] 4.3 Review `docs/capabilities/manipulation/openarm_integration.md` and keep or adjust wording that `openarm_rs` remains opt-in and missing binding failures are selected-adapter scoped. - -## 5. Verification - -- [x] 5.1 Run `openspec validate lazy-manipulator-adapter-registry`. -- [x] 5.2 Run focused registry tests for `dimos/hardware/manipulators/registry.py` and built-in manipulator manifest discovery. -- [x] 5.3 Run focused OpenArm RS/Damiao adapter tests covering `can_motor_control` missing-binding behavior. -- [x] 5.4 Run focused tests for adapters whose manifests changed where construction is safe without hardware. -- [x] 5.5 Run lints/types for changed Python files, including `uv run mypy` or narrower type checks if the repository supports them for this area. -- [x] 5.6 Run documentation validation for changed docs, or manually inspect docs if no doc validation command is available. -- [x] 5.7 Manually QA through the library surface: import `adapter_registry`, call `available()`, create a `mock` adapter, and verify selecting `openarm_rs` without `can_motor_control` fails with a clear selected-adapter error while unrelated adapters remain listed. diff --git a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/.openspec.yaml b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/.openspec.yaml deleted file mode 100644 index c66f6f490c..0000000000 --- a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: dimos-capability -created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/design.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/design.md deleted file mode 100644 index 490b703a05..0000000000 --- a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/design.md +++ /dev/null @@ -1,126 +0,0 @@ -## Context - -DimOS manipulator hardware is selected through `HardwareComponent.adapter_type`, constructed by the manipulator adapter registry, and consumed by `ControlCoordinator` through the existing `ManipulatorAdapter` protocol. Current OpenArm support includes an `openarm` adapter with an in-tree Damiao/CAN implementation and a newer `dm_motor_arm` binding-backed adapter path. - -The current code already has enough configuration hooks for one OpenArm-style DMMotor arm, but the reusable Damiao behavior and OpenArm-specific assumptions are not separated clearly. A fully dynamic URDF-plus-sidecar config loader was considered, but the desired scope is smaller: keep OpenArm explicit, preserve existing OpenArm adapter behavior, and make future Damiao arms straightforward to add by subclassing a shared base. - -Relevant surfaces include `dimos/hardware/manipulators/openarm/adapter.py`, `dimos/hardware/manipulators/dm_motor_arm/adapter.py`, `dimos/hardware/manipulators/registry.py`, `dimos/robot/catalog/openarm.py`, `dimos/robot/manipulators/openarm/blueprints.py`, and `docs/capabilities/manipulation/openarm_integration.md`. - -## Goals / Non-Goals - -**Goals:** - -- Keep `OpenArmAdapter` as the explicit OpenArm adapter and keep `adapter_type="openarm"` stable. -- Extract shared Damiao arm behavior into one reusable base used by OpenArm and any future Damiao-arm subclasses. -- Represent per-arm constants with typed class-level specs rather than broad user-editable sidecar config. -- Preserve existing ControlCoordinator, stream, blueprint, and manipulation task contracts. -- Preserve the explicit `dm_motor_arm` adapter path where it remains useful for binding-backed bring-up. -- Keep hardware safety behavior at least as conservative as the current OpenArm and DMMotor adapters. - -**Non-Goals:** - -- Do not introduce a new runtime-configurable YAML/TOML sidecar schema for arbitrary users in this change. -- Do not remove the existing `openarm` adapter registration. -- Do not silently migrate all OpenArm blueprints to `dm_motor_arm`. -- Do not build or vendor the Rust binding in DimOS; consume the published `can-motor-control` package through the manipulation extra. -- Do not change the public ControlCoordinator stream contract or add new MCP/skill tools. -- Do not implement ros2_control or transmission parsing. - -## DimOS Architecture - -The implementation should keep the same external coordinator shape: - -```text -ControlCoordinator - -> HardwareComponent(adapter_type="openarm" | "dm_motor_arm") - -> manipulator adapter registry - -> OpenArmAdapter or DMMotorArm - -> shared Damiao base behavior - -> can_motor_control binding or existing OpenArm CAN bus path, depending on adapter path -``` - -The shared base should be an internal hardware-layer abstraction, not a new DimOS `Spec` Protocol. The public adapter contract remains `ManipulatorAdapter` from `dimos/hardware/manipulators/spec.py`. No new module streams, transports, or RPC injection contracts are required. - -Recommended code shape: - -```text -dimos/hardware/manipulators/damiao/ - specs.py # typed DamiaoJointSpec / DamiaoArmSpec-style data - base_adapter.py # shared lifecycle/state/command/gravity behavior - -dimos/hardware/manipulators/openarm/adapter.py - OpenArmAdapter(DamiaoArmAdapterBase) - OpenArm v10 left/right specs and OpenArm-specific behavior - -dimos/hardware/manipulators/dm_motor_arm/adapter.py - DMMotorArm(DamiaoArmAdapterBase) or thin compatibility wrapper around the base -``` - -Exact module names can change during implementation, but the boundary should not: common Damiao behavior belongs in one shared hardware-layer implementation, while OpenArm-specific values stay in the OpenArm adapter/catalog layer. - -Blueprints should continue to compose `ControlCoordinator.blueprint(...)` with hardware components generated from existing `RobotConfig` catalog helpers. If runnable blueprint names change, regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py`. If no runnable names change, registry generation is not required. - -## Decisions - -- **Use shallow inheritance, not a dynamic sidecar loader.** A single shared base plus per-arm subclasses matches the current need and keeps new-arm support code-defined and type-checkable. -- **Keep OpenArm explicit.** Existing users select `adapter_type="openarm"`; preserving that surface avoids a silent hardware behavior change. -- **Use typed arm specs for constants.** Motor names, motor types, send/recv IDs, gains, limits, URDF/gravity-model paths, and side-specific metadata should live in immutable data structures owned by subclasses. -- **Keep the ControlCoordinator contract unchanged.** The refactor should be invisible to tasks and stream consumers: joint state and command routing continue through the same surfaces. -- **Do not make URDF the only source of hardware truth.** URDF remains useful for model/gravity/planning, but Damiao motor IDs, CAN details, gains, and signs are hardware facts supplied by subclass specs. -- **Retain lazy optional imports.** Adapters that need optional bindings must not break registry discovery when those packages are unavailable. -- **Prefer composition inside the base over deep inheritance.** One base class is enough; avoid multi-level adapter hierarchies or mixin webs. - -## Safety / Simulation / Replay - -Hardware assumptions: - -- OpenArm hardware continues to run through the existing `openarm` adapter unless a blueprint explicitly selects a different adapter. -- DMMotor/Damiao hardware may require Linux SocketCAN, CAN-FD or classical CAN depending on adapter path, and staged bring-up before full-arm control. -- Gravity compensation uses a configured model path and current measured joint state; incorrect model/sign/offset assumptions can push hardware unexpectedly. - -Safety constraints: - -- Preserve disable-on-disconnect and stop behavior. -- Preserve or improve state freshness/coherence checks before command writes and gravity compensation. -- Do not introduce background command loops that keep running after adapter disconnect. -- Keep gravity-compensation-only behavior free-moving by using zero position stiffness where applicable. -- Validate mock/vcan behavior before real hardware, then one-motor or single-arm staged bring-up before full operation. - -Simulation/replay: - -- Replay is out of scope. -- Mock/vcan adapter tests should cover the refactored shared base. -- Existing OpenArm mock/planner blueprints should remain available. - -Manual QA surface: - -- Registry discovery includes `openarm` and any retained `dm_motor_arm` key. -- Existing OpenArm mock blueprint still builds and runs through the coordinator surface. -- Binding-unavailable behavior remains explicit when selecting binding-backed adapters. -- Mock/vcan DMMotor adapter can connect, enable, read one coherent state snapshot, write position/effort/MIT commands, and disconnect safely. - -## Risks / Trade-offs - -- **Accidental behavior drift:** Moving logic under `OpenArmAdapter` can subtly change current OpenArm behavior. Mitigation: focused before/after adapter tests for limits, gains, motor specs, lifecycle, and command shapes. -- **Over-abstracting too early:** A generic base can become too broad. Mitigation: support only the behavior already shared by OpenArm/DMMotor adapters and keep subclass specs explicit. -- **Optional binding confusion:** `openarm` and `dm_motor_arm` may use different low-level paths. Mitigation: document the distinction and keep missing-binding errors tied only to selected binding-backed adapters. -- **Gravity model mismatch:** Shared gravity code can hide per-arm assumptions. Mitigation: subclass specs must provide model path and torque limits explicitly where gravity compensation is enabled. - -## Migration / Rollout - -1. Add the shared Damiao spec/base layer without changing blueprint names. -2. Move `OpenArmAdapter` onto the shared base while preserving its constructor arguments, registration key, limits, gains, and side behavior. -3. Refactor `DMMotorArm` to reuse the same shared behavior or become a thin compatibility wrapper. -4. Keep existing OpenArm catalog and blueprint selection stable. -5. Add focused tests for shared base behavior, OpenArm compatibility, and retained `dm_motor_arm` behavior. -6. Update OpenArm/manipulation docs to explain that OpenArm is a subclass/preset over shared Damiao behavior. -7. Regenerate blueprint registry only if runnable blueprint names or exported blueprint variables change. - -Rollback is straightforward if the refactor is isolated: keep the previous adapter classes intact until parity tests pass, and avoid changing external blueprint names during the first implementation pass. - -## Open Questions - -- Should the shared base live under `dimos/hardware/manipulators/damiao/` or inside the existing `dm_motor_arm` package? -- Should `DMMotorArm` remain a public generic adapter key, or become an internal compatibility alias after OpenArm uses the shared base? -- Should the initial shared base target only the binding-backed path, or also absorb the in-tree OpenArm CAN driver path? -- How much of OpenArm's current velocity-mode behavior should be retained if the binding-backed path rejects velocity commands? diff --git a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/docs.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/docs.md deleted file mode 100644 index ae3c40730a..0000000000 --- a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/docs.md +++ /dev/null @@ -1,25 +0,0 @@ -## User-Facing Docs - -- Update `docs/capabilities/manipulation/openarm_integration.md` to explain that `openarm` remains the explicit OpenArm adapter while shared Damiao behavior lives underneath it. -- Clarify the distinction between the existing `openarm` adapter path and the opt-in `dm_motor_arm` binding-backed path if both remain user-selectable. -- Update any OpenArm bring-up section only if constructor kwargs, blueprint names, or recommended validation order changes. - -## Contributor Docs - -- No broad contributor documentation is required unless the implementation introduces a reusable pattern that future adapter authors should follow. -- If the shared Damiao base becomes a recommended extension point, add a short note to manipulation contributor docs or the manipulator driver README describing how to add a Damiao-based arm subclass. - -## Coding-Agent Docs - -- No AGENTS.md change is required for the refactor itself. -- If implementation creates a reusable adapter pattern that coding agents should prefer, update `docs/coding-agents/` only after the pattern stabilizes in code and tests. - -## Doc Validation - -- Run documentation link validation for changed docs if available in the repo workflow. -- Run `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` only if executable code blocks in that document are added or changed. -- If blueprint names are changed in docs, also run `pytest dimos/robot/test_all_blueprints_generation.py` after registry regeneration. - -## No Docs Needed - -Documentation updates are needed if the user-visible adapter distinction or extension story changes. If implementation is purely internal and preserves all documented names/commands, docs can be limited to a short architecture note in the OpenArm integration guide. diff --git a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/proposal.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/proposal.md deleted file mode 100644 index 45d8590695..0000000000 --- a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/proposal.md +++ /dev/null @@ -1,39 +0,0 @@ -## Why - -The new `dm_motor_arm` path proves that DimOS can operate Damiao/DMMotor arms through the binding-backed adapter, but the current shape is awkward for future Damiao-based arms: shared Damiao behavior and OpenArm-specific assumptions are mixed together. The earlier idea of fully dynamic URDF-plus-sidecar configuration is more flexible than this project currently needs and would add a larger configuration surface than necessary. - -This change should refactor the adapter structure around a small class hierarchy: keep `OpenArmAdapter` as the explicit OpenArm adapter, extract reusable Damiao/CAN/MIT behavior into a shared base, and let future Damiao arms opt in by subclassing with their own class-level arm specs. - -## What Changes - -- Add a reusable Damiao arm adapter base that owns generic binding/CAN lifecycle, state reads, MIT command writes, gravity compensation plumbing, and shared validation. -- Keep `OpenArmAdapter` and the `openarm` adapter registration as the stable OpenArm-specific surface. -- Move OpenArm-specific motor tables, URDF paths, gains, limits, side handling, and model metadata into the OpenArm subclass/spec rather than keeping them as generic `DMMotorArm` defaults. -- Preserve the opt-in `dm_motor_arm` adapter path where useful, but make its relationship to the shared Damiao base explicit. -- Avoid introducing a broad user-editable sidecar schema in this change; new Damiao arms can be added through typed subclasses and catalog/blueprint presets. -- No **BREAKING** public CLI or blueprint behavior is intended; existing OpenArm blueprints should keep selecting `openarm` unless explicitly migrated. - -## Affected DimOS Surfaces - -- Modules/streams: Manipulator adapter implementations behind the existing `ManipulatorAdapter` protocol; ControlCoordinator stream behavior should remain unchanged. -- Blueprints/CLI: Existing OpenArm blueprints and `dimos run` entries should remain stable; opt-in DMMotor/OpenArm blueprints may be adjusted only to use the refactored adapter classes. -- Skills/MCP: No direct skill or MCP changes planned. -- Hardware/simulation/replay: Real OpenArm/Damiao hardware lifecycle, mock/vcan validation, gravity compensation, enable/disable safety, and command semantics. -- Docs/generated registries: OpenArm/manipulation documentation may need updates to explain the adapter split; blueprint registry regeneration is needed only if runnable blueprint names change. - -## Capabilities - -### New Capabilities - -- `damiao-arm-adapter-refactor`: Covers the reusable Damiao arm adapter structure, subclass-based arm specs, and preservation of OpenArm adapter behavior. -- `openarm-adapter-compatibility`: Covers compatibility expectations for existing OpenArm adapter registration, blueprints, hardware behavior, and staged QA. - -### Modified Capabilities - -- None. - -## Impact - -Developers get a simpler extension path for additional Damiao-based arms without introducing a large dynamic configuration format. OpenArm remains a named adapter with explicit OpenArm behavior, while shared Damiao code becomes easier to test and reuse. - -Compatibility risk is mainly around preserving the current `openarm` adapter semantics while moving shared logic underneath it. Hardware safety QA must cover enable/disable, state read coherence, position/effort/MIT commands, gravity-compensation behavior, and mock/vcan smoke tests before real hardware validation. No new runtime dependency installation is planned. diff --git a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md deleted file mode 100644 index 8a99cf6d67..0000000000 --- a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/damiao-arm-adapter-refactor/spec.md +++ /dev/null @@ -1,43 +0,0 @@ -## ADDED Requirements - -### Requirement: Reusable Damiao arm behavior - -DimOS SHALL provide a reusable Damiao arm adapter behavior that can be shared by OpenArm and future Damiao-based manipulator adapters without requiring users to define a broad dynamic hardware configuration schema. - -#### Scenario: Adding a Damiao-based arm implementation -- **GIVEN** a developer needs to add a new arm composed of Damiao motors -- **WHEN** the arm behavior fits the shared Damiao lifecycle, state-read, and MIT command model -- **THEN** the developer SHALL be able to define an arm-specific implementation by providing typed arm metadata and subclass-specific behavior -- **AND** they SHALL NOT need to duplicate the shared Damiao lifecycle and command plumbing. - -#### Scenario: Preserving coordinator behavior -- **GIVEN** a Damiao-based adapter uses the shared behavior -- **WHEN** ControlCoordinator reads state or writes supported joint commands through the manipulator adapter surface -- **THEN** DimOS SHALL preserve the existing manipulator state and command semantics -- **AND** no new stream contract SHALL be required for the refactor. - -### Requirement: Explicit arm-specific metadata - -DimOS SHALL keep arm-specific Damiao metadata explicit and typed rather than relying on implicit OpenArm defaults inside generic Damiao behavior. - -#### Scenario: Constructing a non-OpenArm Damiao adapter -- **GIVEN** a Damiao-based adapter does not represent OpenArm -- **WHEN** it is implemented using the shared Damiao behavior -- **THEN** it SHALL provide its own motor layout, gains, joint order, model/gravity metadata, and hardware assumptions -- **AND** DimOS SHALL NOT silently use OpenArm's motor table or OpenArm-specific defaults for that adapter. - -#### Scenario: Validating shared command shape -- **GIVEN** a Damiao adapter accepts a supported position, effort, or MIT-style command -- **WHEN** the command is forwarded to hardware or a mock transport -- **THEN** the command SHALL be ordered according to the adapter's explicit arm metadata -- **AND** command length mismatches SHALL be rejected or surfaced as adapter errors rather than sent to hardware. - -### Requirement: Optional binding behavior remains selected-adapter scoped - -DimOS SHALL keep optional Damiao binding availability failures scoped to adapters that actually select the binding-backed path. - -#### Scenario: Binding unavailable while discovering adapters -- **GIVEN** an optional Damiao binding is not importable in the runtime environment -- **WHEN** DimOS discovers manipulator adapters -- **THEN** adapter discovery SHALL continue for adapters that do not require that binding at discovery time -- **AND** missing-binding errors SHALL be raised only when selecting or connecting an adapter that requires the binding. diff --git a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md deleted file mode 100644 index d0092d7f6d..0000000000 --- a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/specs/openarm-adapter-compatibility/spec.md +++ /dev/null @@ -1,43 +0,0 @@ -## ADDED Requirements - -### Requirement: OpenArm adapter compatibility - -DimOS SHALL preserve the existing OpenArm adapter selection and observable behavior while refactoring shared Damiao behavior underneath it. - -#### Scenario: Selecting the OpenArm adapter -- **GIVEN** an existing hardware configuration selects the OpenArm adapter -- **WHEN** DimOS initializes the manipulator hardware -- **THEN** DimOS SHALL continue to create an OpenArm-specific adapter through the existing adapter selection surface -- **AND** the adapter SHALL preserve OpenArm-specific side, joint, motor, limit, gain, and gravity-model behavior. - -#### Scenario: Existing OpenArm blueprints -- **GIVEN** an existing OpenArm blueprint selects the current OpenArm adapter path -- **WHEN** this refactor is present -- **THEN** the blueprint SHALL continue selecting the OpenArm adapter unless it is explicitly changed -- **AND** existing runnable blueprint names SHALL remain stable unless a generated-registry update intentionally changes them. - -### Requirement: OpenArm hardware safety preservation - -OpenArm adapter behavior SHALL remain at least as safe as the pre-refactor behavior for enablement, command writes, gravity compensation, and shutdown. - -#### Scenario: Stopping or disconnecting OpenArm hardware -- **GIVEN** OpenArm motors are enabled through DimOS -- **WHEN** the adapter is stopped or disconnected -- **THEN** DimOS SHALL attempt to disable or stop commanding the motors through the adapter -- **AND** the refactor SHALL NOT introduce a continued background command loop after disconnect. - -#### Scenario: OpenArm gravity compensation -- **GIVEN** OpenArm gravity compensation is enabled -- **WHEN** DimOS computes and sends supported OpenArm commands -- **THEN** gravity feed-forward SHALL use the OpenArm-specific model and current measured joint state -- **AND** invalid or stale state SHALL prevent unsafe gravity-compensation commands from being sent. - -### Requirement: Staged OpenArm validation - -DimOS SHALL support staged validation of refactored OpenArm adapter behavior before real full-arm use. - -#### Scenario: Mock or virtual validation -- **GIVEN** the refactored OpenArm or Damiao adapter path is available -- **WHEN** a developer validates the change without real hardware -- **THEN** DimOS SHALL support mock or virtual transport tests for lifecycle, state reads, supported commands, and shutdown -- **AND** real hardware validation SHALL be treated as a later staged QA step. diff --git a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/tasks.md b/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/tasks.md deleted file mode 100644 index 04f6425869..0000000000 --- a/openspec/changes/archive/2026-06-06-refactor-damiao-arm-adapters/tasks.md +++ /dev/null @@ -1,43 +0,0 @@ -## 1. Shared Damiao Adapter Structure - -- [x] 1.1 Add typed Damiao arm metadata structures for motor layout, gains, limits, gravity model, and optional binding/bus assumptions. -- [x] 1.2 Add a shared Damiao arm adapter base that centralizes reusable lifecycle, state-read, command-write, validation, and gravity-compensation behavior. -- [x] 1.3 Keep optional binding imports lazy so adapter discovery does not fail when optional Damiao bindings are unavailable. -- [x] 1.4 Add tests for shared metadata validation, including duplicate IDs, command length mismatch, joint-order preservation, and missing optional binding behavior. - -## 2. OpenArm Compatibility Refactor - -- [x] 2.1 Refactor `OpenArmAdapter` to use the shared Damiao base while preserving the `openarm` adapter registration key. -- [x] 2.2 Move OpenArm v10 motor tables, side-specific URDF/gravity paths, gains, limits, and side handling into OpenArm-specific typed specs. -- [x] 2.3 Preserve OpenArm constructor arguments and externally observable behavior for existing OpenArm hardware configs. -- [x] 2.4 Add focused OpenArm adapter parity tests for info, limits, gains, motor specs, lifecycle, state reads, supported command shapes, gravity compensation, and disconnect/stop behavior. - -## 3. DMMotor Adapter Alignment - -- [x] 3.1 Refactor `DMMotorArm` to reuse the shared Damiao base or become a thin compatibility wrapper around it. -- [x] 3.2 Preserve `dm_motor_arm` adapter registration and selected-adapter missing-binding errors. -- [x] 3.3 Preserve binding-backed mock/vcan behavior for connect, enable, coherent state reads, position writes, effort/MIT writes, gravity-only commands, and safe disconnect. -- [x] 3.4 Add focused `dm_motor_arm` tests proving OpenArm defaults are not treated as generic defaults for non-OpenArm Damiao subclasses. - -## 4. Blueprints and Catalogs - -- [x] 4.1 Keep existing OpenArm catalog helpers and blueprint names stable unless an explicit migration is required. -- [x] 4.2 Update opt-in DMMotor/OpenArm blueprint wiring only if needed to select the refactored adapter classes. -- [x] 4.3 Run `pytest dimos/robot/test_all_blueprints_generation.py` and update `dimos/robot/all_blueprints.py` only if runnable blueprint exports change. - -## 5. Documentation - -- [x] 5.1 Update `docs/capabilities/manipulation/openarm_integration.md` to explain that `openarm` remains explicit while shared Damiao behavior lives underneath it. -- [x] 5.2 Clarify the distinction between `openarm` and `dm_motor_arm` adapter paths if both remain user-selectable. -- [x] 5.3 Update manipulator driver/contributor docs only if the shared Damiao base becomes the recommended extension point for future Damiao arms. - -## 6. Verification - -- [x] 6.1 Run `openspec validate refactor-damiao-arm-adapters`. -- [x] 6.2 Run focused tests for `dimos/hardware/manipulators/openarm/`. -- [x] 6.3 Run focused tests for `dimos/hardware/manipulators/dm_motor_arm/` and any new shared Damiao adapter package. -- [x] 6.4 Run focused ControlCoordinator or hardware-interface tests if adapter/coordinator integration behavior changes. -- [x] 6.5 Run docs validation for changed docs, including `md-babel-py run docs/capabilities/manipulation/openarm_integration.md` if executable blocks are added or changed. -- [x] 6.6 Manually QA registry discovery through the library surface by confirming `openarm` and retained `dm_motor_arm` adapter keys are available. -- [x] 6.7 Manually QA mock/vcan adapter operation through the library or coordinator surface: connect, enable, read state, write a supported command, stop, and disconnect. -- [ ] 6.8 Manually QA real OpenArm hardware only after mock/vcan validation: one-arm enable/read, low-rate hold or gravity compensation, safe stop, and optional trajectory-control validation. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/.openspec.yaml b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/.openspec.yaml deleted file mode 100644 index e18884737b..0000000000 --- a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: dimos-capability -created: 2026-06-08 diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/design.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/design.md deleted file mode 100644 index 644cc677bf..0000000000 --- a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/design.md +++ /dev/null @@ -1,75 +0,0 @@ -## Context - -The branch adds and changes manipulator adapter tests around Damiao/OpenArm/OpenArm RS adapters and lazy manipulator adapter discovery. Several tests currently over-assert details such as private adapter fields, full default gain tables, motor-spec internals, and command-matrix columns. These details may be relevant inside an implementation, but tests that assert all of them at construction time do not communicate the behavior being protected. - -The repository already has testing guidance in `docs/development/testing.md` and stricter coding-agent guidance in `docs/coding-agents/testing.md`. The current guidance covers fixtures, cleanup, imports, sleeps, prints, and deterministic assertions, but it does not explicitly warn against low-value object-shape tests. - -## Goals / Non-Goals - -**Goals:** - -- Refine manipulator tests so each test has a clear setup, execution step, and observable expected result. -- Preserve coverage for behavior that matters while excluding fake control-binding behavior from OpenArm RS unit tests. -- Remove or collapse tests that only assert implementation shape or every minor detail of constructed objects. -- Add coding-agent documentation that describes behavior-focused test structure and an over-assertion review checklist. - -**Non-Goals:** - -- Change runtime adapter behavior, hardware protocol behavior, CLI behavior, streams, blueprints, or public APIs. -- Add new manipulator features. -- Require real hardware, simulation, or replay QA for this docs/test-only refinement. -- Turn every existing repository test into the new style in this change. - -## DimOS Architecture - -This change is limited to tests and contributor/coding-agent documentation. - -- Manipulator adapter Protocol surface: OpenArm RS tests should exercise constructor-time public metadata and validation only, without mocking or driving `can_motor_control`. -- Manipulator adapter registry: tests should exercise `available()` and selected `create()` behavior without importing unselected hardware SDKs. -- Modules/streams/transports/blueprints/RPC: no production architecture changes. -- Skills/MCP/CLI: no changes. -- Generated registries: no expected regeneration. - -## Decisions - -1. **Test behavior through public surfaces rather than private fields.** - - Rationale: private fields and construction details are refactor-sensitive and often do not reveal which behavior matters. - - Alternative considered: retain broad object snapshots as regression tests. Rejected because they increase maintenance cost and hide the real behavioral contract. - -2. **Use fakes to observe effects at integration boundaries.** - - Rationale: adapter tests need to verify that calls reach backend abstractions without real hardware. Fake backends should expose small observations such as "last position target" or "transport fd mode" rather than requiring tests to inspect every internal matrix cell. - - Alternative considered: keep direct assertions on backend command matrix columns. Some targeted matrix assertions may remain when they are the smallest way to prove a safety behavior, but they should be named and scoped to that behavior. - -3. **Prune tests that cannot name the desired behavior.** - - Rationale: if a test name cannot state the behavior it protects, the test is likely asserting object shape rather than functionality. - - Alternative considered: rename all tests without deleting any. Rejected because renamed over-assertive tests still constrain implementation unnecessarily. - -4. **Document the mistake in coding-agent guidance.** - - Rationale: the issue was produced by agent-written tests, so the durable fix needs guidance where coding agents look before writing tests. - -## Safety / Simulation / Replay - -No runtime hardware behavior changes are intended. Test refinement must preserve safety-relevant behavioral checks, especially: - -- OpenArm/OpenArm RS commands that hold or stop hardware safely. -- Gravity-compensation behavior where the adapter intentionally sends torque/damping behavior instead of a stiff position command. -- Lazy registry behavior so partial installations remain safe and understandable without OpenArm RS tests simulating the control binding. - -Manual QA surface is the focused pytest suite for changed manipulator tests plus inspection of documentation rendering/link validity. Real hardware, MuJoCo, replay, and MCP surfaces are out of scope. - -## Risks / Trade-offs - -- **Risk: pruning too much coverage.** Mitigation: keep tests for named behavior contracts and error boundaries before removing detail assertions. -- **Risk: hiding safety-critical constants.** Mitigation: if a constant table is safety-critical, test the resulting command behavior and name the safety reason explicitly. -- **Risk: documentation becomes generic advice.** Mitigation: include concrete bad/good examples that mirror DimOS adapter tests. - -## Migration / Rollout - -- Update only test files and `docs/coding-agents/testing.md`. -- Run focused manipulator tests after implementation. -- Run documentation link validation if available for the changed docs. -- No data migration, generated registry update, dependency change, or deployment step is required. - -## Open Questions - -- Which existing over-assertive tests should be deleted outright versus collapsed into broader behavior tests will be decided during implementation by mapping each assertion to a named behavior. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/docs.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/docs.md deleted file mode 100644 index b2bd6cef97..0000000000 --- a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/docs.md +++ /dev/null @@ -1,22 +0,0 @@ -## User-Facing Docs - -None. This change does not alter user-facing DimOS behavior, hardware setup, CLI commands, skills/MCP tools, or platform capability guides. - -## Contributor Docs - -No broad contributor-process update is required in `docs/development/`. The existing `docs/development/testing.md` already covers test location, pytest usage, fixtures, mocking, options, and markers. - -## Coding-Agent Docs - -Do not add this guidance to `docs/coding-agents/testing.md`. The durable location is the OpenSpec schema instructions that generate and apply implementation task lists, because the mistake occurs when agents plan and execute test tasks. - -Update `openspec/schemas/dimos-capability/schema.yaml` so future `tasks` and `apply` instructions tell agents to write behavior-focused tests: set up the test, execute functionality, and check the desired result. The prompt guidance should warn against tests that only construct objects and assert private fields, full metadata snapshots, default tables, or fake backend internals. - -## Doc Validation - -- `openspec validate improve-behavior-focused-test-guidelines` -- Manually inspect the OpenSpec instructions output for a future apply/tasks flow if prompt wording changes are significant. - -## No Docs Needed - -User-facing documentation is not needed because runtime behavior does not change. The guidance belongs in OpenSpec prompt instructions rather than general coding-agent docs. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/proposal.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/proposal.md deleted file mode 100644 index 2b3bc10dc1..0000000000 --- a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/proposal.md +++ /dev/null @@ -1,37 +0,0 @@ -## Why - -Recent manipulator adapter tests include low-value assertions that construct objects and verify many incidental details, such as private fields, full default tables, and backend command matrix internals. Those tests are hard to review because the desired behavior is unclear, and they make future refactors feel risky even when public behavior is unchanged. - -This change refines the new manipulator tests so they check functionality through the adapter/registry surfaces, and strengthens OpenSpec task/apply prompts so future generated test work follows setup, execute, and verify structure instead of over-asserting implementation shape. - -## What Changes - -- Refactor low-value manipulator adapter tests into behavior-focused tests with clear setup, action, and desired outcome. -- Remove or collapse tests that only assert object construction details, private attributes, default metadata snapshots, or every minor backend command field. -- Preserve tests for real behavioral contracts while keeping OpenArm RS unit tests limited to non-control-binding behavior. -- Add explicit OpenSpec prompt guidance for writing behavior-focused unit tests and avoiding over-assertive object-shape tests. -- No public API, CLI, stream, hardware protocol, or runtime behavior changes are intended. - -## Affected DimOS Surfaces - -- Modules/streams: none at runtime; tests exercise manipulator adapter and registry surfaces. -- Blueprints/CLI: none. -- Skills/MCP: none. -- Hardware/simulation/replay: no hardware behavior changes; OpenArm RS unit tests avoid fake `can_motor_control` hardware behavior. -- Docs/generated registries: `openspec/schemas/dimos-capability/schema.yaml`; no generated registries expected. - -## Capabilities - -### New Capabilities -- `behavior-focused-test-guidelines`: Contributor and coding-agent expectations for writing unit tests that verify observable behavior rather than incidental object shape. - -### Modified Capabilities -- `dm-motor-manipulator-adapters`: Clarify test coverage expectations for manipulator adapter behavior without changing adapter requirements. -- `manipulator-adapter-discovery`: Clarify test coverage expectations for lazy registry discovery without changing registry requirements. -- `openarm-rs-adapter-selection`: Clarify test coverage expectations for OpenArm RS adapter behavior without changing adapter selection requirements. - -## Impact - -Developers get a clearer, smaller test suite that fails when functionality regresses rather than when implementation details shift. OpenSpec-driven implementation agents get concrete guidance to avoid repeating the over-assertion mistake. - -Compatibility risk is low because the change is limited to tests and documentation. Test/QA scope is the focused manipulator test set plus a documentation review of the new behavior-focused testing guidance. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/behavior-focused-test-guidelines/spec.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/behavior-focused-test-guidelines/spec.md deleted file mode 100644 index 21d21f4e80..0000000000 --- a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/behavior-focused-test-guidelines/spec.md +++ /dev/null @@ -1,28 +0,0 @@ -## ADDED Requirements - -### Requirement: Behavior-focused unit test guidance - -DimOS SHALL provide coding-agent testing guidance that tells agents to write unit tests around observable behavior rather than incidental object shape. - -#### Scenario: Agent prepares to write a unit test -- **GIVEN** a coding agent is writing or reviewing a unit test -- **WHEN** it consults the DimOS coding-agent testing guidance -- **THEN** the guidance SHALL instruct it to structure the test as setup, execute functionality, and check the desired result -- **AND** the guidance SHALL discourage tests that only construct an object and assert many minor properties. - -#### Scenario: Agent reviews an over-assertive test -- **GIVEN** a proposed test asserts private fields, full metadata snapshots, or every backend command detail -- **WHEN** the agent applies the testing guidance -- **THEN** it SHALL either replace those assertions with a public behavior check or justify the smallest safety-critical assertion needed -- **AND** it SHALL prefer a test name that states the desired behavior being protected. - -### Requirement: Test-quality review checklist - -DimOS SHALL provide a concise checklist for coding agents to evaluate whether a unit test is behavior-focused. - -#### Scenario: Agent self-reviews a new test -- **GIVEN** a coding agent has written a new unit test -- **WHEN** it applies the checklist -- **THEN** the checklist SHALL ask what behavior would break if the test failed -- **AND** it SHALL ask whether the test executed functionality rather than only constructing an object -- **AND** it SHALL ask whether the assertions target public outcomes instead of private implementation details. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/dm-motor-manipulator-adapters/spec.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/dm-motor-manipulator-adapters/spec.md deleted file mode 100644 index f9b676074b..0000000000 --- a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/dm-motor-manipulator-adapters/spec.md +++ /dev/null @@ -1,30 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Binding-backed adapter safety behavior - -DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. Tests for this behavior SHALL verify observable adapter outcomes and safety-relevant command effects rather than broad snapshots of private adapter construction state. - -#### Scenario: Coherent state reads -- **GIVEN** the OpenArm RS adapter is connected through the binding-backed path -- **WHEN** DimOS reads positions, velocities, and efforts during one coordinator cycle -- **THEN** the adapter SHALL provide a coherent state snapshot rather than independently loading the CAN bus for each read -- **AND** stale or malformed state SHALL be surfaced before unsafe commands are sent. - -#### Scenario: Gravity-compensation-only command -- **GIVEN** a user invokes a gravity-compensation-only validation path through the binding-backed adapter -- **WHEN** the adapter sends MIT commands for gravity compensation -- **THEN** the command SHALL use zero position stiffness so the arm remains manually movable -- **AND** the command SHALL use current measured state and configured OpenArm gravity metadata. - -#### Scenario: Missing binding remains selected-adapter scoped -- **GIVEN** the optional binding is not installed -- **WHEN** DimOS discovers or lists manipulator adapters without selecting OpenArm RS -- **THEN** discovery SHALL continue for adapters that do not require the binding -- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path -- **AND** registry listing MUST NOT import the OpenArm RS implementation only to discover its adapter key. - -#### Scenario: Safety tests remain behavior-focused -- **GIVEN** a test protects binding-backed adapter safety behavior -- **WHEN** the test verifies command output or state behavior -- **THEN** it SHALL name the safety behavior being protected -- **AND** it SHALL avoid asserting unrelated private fields or full default tables that are not required to prove that behavior. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/manipulator-adapter-discovery/spec.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/manipulator-adapter-discovery/spec.md deleted file mode 100644 index 00756c8100..0000000000 --- a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/manipulator-adapter-discovery/spec.md +++ /dev/null @@ -1,45 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Metadata-only manipulator adapter listing - -DimOS SHALL list registered manipulator adapter keys without importing unselected adapter implementation modules. Tests for this requirement SHALL verify discovery behavior through registry listing and module-import observability rather than asserting incidental registry object internals. - -#### Scenario: Listing adapters in a partial installation -- **GIVEN** a DimOS environment where one or more optional manipulator hardware SDKs are not installed -- **WHEN** a user imports the manipulator adapter registry and asks for available adapter keys -- **THEN** DimOS SHALL return the known adapter keys whose lightweight registry metadata is available -- **AND** DimOS MUST NOT import unselected adapter implementation modules only to produce that list. - -#### Scenario: Unrelated adapter remains discoverable -- **GIVEN** an optional binding required by one manipulator adapter is not importable -- **WHEN** a user lists manipulator adapters without selecting that adapter -- **THEN** DimOS SHALL continue discovering unrelated manipulator adapters -- **AND** missing optional dependency errors SHALL NOT prevent unrelated adapter keys from appearing. - -### Requirement: Selected manipulator adapter loading - -DimOS SHALL import and instantiate only the manipulator adapter selected by `adapter_registry.create(name, **kwargs)`. Tests for selected loading SHALL exercise the create call and observe the selected adapter result or actionable error. - -#### Scenario: Creating a selected adapter -- **GIVEN** a registered manipulator adapter key and constructor arguments for that adapter -- **WHEN** a user calls `adapter_registry.create()` with that key -- **THEN** DimOS SHALL resolve the selected adapter implementation -- **AND** DimOS SHALL instantiate the selected adapter with the provided arguments. - -#### Scenario: Unknown adapter name -- **GIVEN** a manipulator adapter key is not registered -- **WHEN** a user calls `adapter_registry.create()` with that key -- **THEN** DimOS SHALL fail with an error that identifies the unknown adapter -- **AND** the error SHALL include the currently available adapter keys. - -#### Scenario: Broken selected adapter registration -- **GIVEN** a manipulator adapter key is registered to an implementation path that cannot be resolved -- **WHEN** a user selects that adapter key -- **THEN** DimOS SHALL fail with an actionable selected-adapter error -- **AND** the error SHALL identify the selected adapter key or its configured implementation path. - -#### Scenario: Registry tests remain behavior-focused -- **GIVEN** a test covers manipulator adapter discovery or selected loading -- **WHEN** it verifies the registry behavior -- **THEN** it SHALL observe available keys, selected instantiation, import side effects, or actionable errors -- **AND** it SHALL avoid asserting registry internals that are not part of the developer-visible contract. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/openarm-rs-adapter-selection/spec.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/openarm-rs-adapter-selection/spec.md deleted file mode 100644 index 03b0ccf27b..0000000000 --- a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/specs/openarm-rs-adapter-selection/spec.md +++ /dev/null @@ -1,34 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Explicit OpenArm RS adapter selection - -DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. Unit tests for the OpenArm RS adapter SHALL avoid fake `can_motor_control` hardware behavior and cover only constructor-time public metadata and validation behavior. - -#### Scenario: Selecting the Rust-backed OpenArm path -- **GIVEN** a hardware configuration selects the binding-backed OpenArm adapter -- **WHEN** DimOS initializes manipulator hardware through the adapter registry -- **THEN** the configuration SHALL select the adapter using the `openarm_rs` key -- **AND** the selected adapter SHALL represent OpenArm hardware rather than a generic Damiao arm. - -#### Scenario: Binding unavailable for OpenArm RS -- **GIVEN** the `can_motor_control` binding is not importable in the runtime environment -- **WHEN** a user selects the `openarm_rs` adapter -- **THEN** DimOS SHALL fail with a clear selected-adapter missing-binding error -- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding -- **AND** listing manipulator adapters MUST NOT import the OpenArm RS implementation only to discover its adapter key. - -### Requirement: OpenArm RS safety staging - -DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. Unit tests for this path SHALL NOT simulate backend control-library effects with fake hardware classes. - -#### Scenario: Validating before real hardware operation -- **GIVEN** a user wants to run OpenArm hardware through the `openarm_rs` path -- **WHEN** they follow DimOS bring-up guidance -- **THEN** the guidance SHALL start with binding availability and mock or virtual CAN validation -- **AND** it SHALL treat real hardware gravity compensation and trajectory execution as later staged QA steps. - -#### Scenario: OpenArm RS tests remain behavior-focused -- **GIVEN** a test covers OpenArm RS adapter behavior -- **WHEN** it verifies behavior without the real control binding -- **THEN** it SHALL limit coverage to constructor-time metadata, limits, registration, and validation errors -- **AND** it SHALL avoid fake `can_motor_control` robots, buses, arms, state reads, and command effects. diff --git a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/tasks.md b/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/tasks.md deleted file mode 100644 index e655036eb4..0000000000 --- a/openspec/changes/archive/2026-06-08-improve-behavior-focused-test-guidelines/tasks.md +++ /dev/null @@ -1,20 +0,0 @@ -## 1. Implementation - -- [x] 1.1 Audit `dimos/hardware/manipulators/openarm_rs/test_adapter.py`, `dimos/hardware/manipulators/openarm/test_adapter.py`, `dimos/hardware/manipulators/damiao/test_base_adapter.py`, and `dimos/hardware/manipulators/test_registry.py` for tests that only assert construction shape, private fields, full metadata snapshots, or unrelated backend command details. -- [x] 1.2 Delete or collapse tests whose behavior cannot be stated clearly in the test name. -- [x] 1.3 Rewrite retained manipulator adapter tests so each follows setup, execute functionality, and check desired result through public adapter or fake-backend observations. -- [x] 1.4 Preserve behavior coverage while limiting OpenArm RS unit tests to non-control-binding metadata, registration, limits, and validation behavior. -- [x] 1.5 Keep safety-critical command assertions only when they are the smallest meaningful proof of the named safety behavior. - -## 2. OpenSpec Prompt Guidance - -- [x] 2.1 Remove the behavior-focused testing section from `docs/coding-agents/testing.md`. -- [x] 2.2 Update `openspec/schemas/dimos-capability/schema.yaml` tasks instructions with behavior-focused testing guidance. -- [x] 2.3 Update `openspec/schemas/dimos-capability/schema.yaml` apply instructions so implementation agents avoid low-value object-shape tests. - -## 3. Verification - -- [x] 3.1 Run `openspec validate improve-behavior-focused-test-guidelines`. -- [x] 3.2 Run `uv run pytest dimos/hardware/manipulators/openarm_rs/test_adapter.py dimos/hardware/manipulators/openarm/test_adapter.py dimos/hardware/manipulators/damiao/test_base_adapter.py dimos/hardware/manipulators/test_registry.py -q`. -- [x] 3.3 Run `openspec validate improve-behavior-focused-test-guidelines` after OpenSpec prompt changes. -- [x] 3.4 Manually QA the test-quality change by reviewing the final changed tests and confirming each retained test has an explicit setup, execution step, and desired behavioral result. diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 62a72bba63..0000000000 --- a/openspec/config.yaml +++ /dev/null @@ -1,45 +0,0 @@ -schema: dimos-capability - -context: | - DimOS is a robotics operating system for generalist robots. Modules communicate - through typed streams (`In[T]`, `Out[T]`) over LCM, SHM, ROS, DDS, or other - transports. Blueprints compose modules into runnable robot stacks. Skills are - `@skill`-annotated RPC methods exposed to agents and MCP clients. - - Terminology boundary: - - "OpenSpec spec" means a behavior specification under `openspec/specs/`. - - "DimOS Spec" means a Python Protocol/RPC contract in `*_spec.py` files, - usually inheriting `dimos.spec.utils.Spec` and `typing.Protocol`. - Keep these separate. OpenSpec specs describe observable behavior; DimOS Specs - describe code-level module interfaces. - - OpenSpec specs should capture current behavior, user/developer-visible - outcomes, public CLI/API/tool surfaces, robot safety constraints, and testable - scenarios. Put implementation choices, class names, module wiring, generated - registry updates, and rollout details in `design.md` or `tasks.md`. - - Documentation lives in: - - `docs/usage/` for user-facing concepts and APIs. - - `docs/capabilities/` for capability and platform guides. - - `docs/development/` for contributor process. - - `docs/coding-agents/` and `AGENTS.md` for coding-agent guidance. - -rules: - proposal: - - "Identify affected DimOS surfaces: modules, streams, blueprints, CLI, skills/MCP, docs, hardware, simulation, replay, or generated registries." - - Use capability names that match behavior domains, not Python class names. - - Mark hardware safety or public API/CLI changes explicitly. - specs: - - Write behavior-first requirements; avoid implementation detail unless it is externally observable. - - Every requirement must include at least one `#### Scenario:` block with concrete observable outcomes. - - Use "OpenSpec capability spec" when prose might otherwise be confused with DimOS Python `Spec` Protocols. - design: - - Call out DimOS `Spec` Protocols, adapter Protocols, blueprint composition, stream names/types, and skill/MCP exposure when relevant. - - Mention generated files and required regeneration commands, especially `pytest dimos/robot/test_all_blueprints_generation.py` for blueprint registry changes. - - Include hardware/simulation/replay assumptions and safety constraints for robot-facing work. - docs: - - List user-facing docs, contributor docs, coding-agent docs, and AGENTS.md updates required by the change. - - Include documentation validation commands for changed docs, such as `doclinks` and `md-babel-py run ` where applicable. - tasks: - - Include verification tasks for OpenSpec validation, relevant pytest targets, type checks when needed, and manual QA through the user-facing surface. - - Add registry generation tasks when blueprint names, module classes, or generated registry inputs change. diff --git a/openspec/schemas/dimos-capability/schema.yaml b/openspec/schemas/dimos-capability/schema.yaml deleted file mode 100644 index cbc073e1c7..0000000000 --- a/openspec/schemas/dimos-capability/schema.yaml +++ /dev/null @@ -1,131 +0,0 @@ -name: dimos-capability -version: 1 -description: DimOS capability workflow - proposal → specs/design/docs → tasks -artifacts: - - id: proposal - generates: proposal.md - description: DimOS change proposal covering intent, scope, capability impact, and affected robot/software surfaces - template: proposal.md - instruction: | - Create the proposal document that establishes WHY this change is needed and what DimOS behavior it affects. - - Sections: - - **Why**: 1-2 concise paragraphs on the problem or opportunity. Explain why the change matters now. - - **What Changes**: Bullet list of added, modified, or removed behavior. Mark public API/CLI or hardware-safety breaking changes with **BREAKING**. - - **Affected DimOS Surfaces**: Identify modules, streams, blueprints, CLI commands, skills/MCP tools, docs, hardware, simulation, replay, generated registries, or external protocols touched by the change. - - **Capabilities**: Identify which OpenSpec capability specs will be created or modified: - - **New Capabilities**: List behavior domains introduced by the change. Each becomes `specs//spec.md`. Use kebab-case names (for example, `agent-skills-mcp`, `blueprint-composition`, `manipulation-stack`). - - **Modified Capabilities**: List existing `openspec/specs//` entries whose requirements change. Only include spec-level behavior changes, not implementation-only refactors. - - **Impact**: Summarize user/developer impact, compatibility risks, dependency changes, documentation updates, and test/QA scope. - - Keep proposals concise. Do not include line-by-line implementation details; put architecture and rollout decisions in `design.md`. - requires: [] - - id: specs - generates: specs/**/*.md - description: Behavior-first OpenSpec capability delta specifications - template: spec.md - instruction: | - Create OpenSpec capability specs that define WHAT DimOS should do, not how it is implemented. - - Create one delta spec file per capability listed in proposal.md: - - New capabilities: use `specs//spec.md` with the exact kebab-case name from the proposal. - - Modified capabilities: use the existing folder from `openspec/specs//`. - - Use these delta sections as `##` headers: - - **ADDED Requirements**: New externally observable behavior. - - **MODIFIED Requirements**: Changed behavior. Include the full updated requirement block, not a partial patch. - - **REMOVED Requirements**: Deprecated behavior. Include **Reason** and **Migration**. - - **RENAMED Requirements**: Name-only changes. Use FROM:/TO: format. - - Requirement format: - - Use `### Requirement: `. - - Use SHALL/MUST for normative requirements. - - Include at least one `#### Scenario: ` per requirement. Scenario headings MUST use exactly four `#` characters. - - Prefer `- **GIVEN**`, `- **WHEN**`, `- **THEN**`, and `- **AND**` bullets. - - Cover happy path plus meaningful edge/error/safety cases. - - DimOS-specific guidance: - - Specify user/developer-visible behavior, robot outcomes, CLI behavior, skill/MCP tool behavior, stream contracts, safety constraints, and compatibility expectations. - - Avoid Python class names, private module internals, transport implementation choices, and generated-file details unless those details are observable API contracts. - - Use "OpenSpec capability spec" in prose when needed to avoid confusion with DimOS Python `Spec` Protocols. - - If the behavior only changes implementation and not observable requirements, do not create a spec delta. - requires: - - proposal - - id: design - generates: design.md - description: DimOS technical design and architecture decisions - template: design.md - instruction: | - Create the design document that explains HOW the change should be implemented in DimOS. - - Include design.md for cross-module changes, new robot/hardware integration, new public interfaces, new dependencies, safety-sensitive behavior, generated registry changes, or unclear architecture. - - Sections: - - **Context**: Current state, relevant modules/blueprints/docs, and constraints. - - **Goals / Non-Goals**: What the design achieves and explicitly excludes. - - **DimOS Architecture**: Modules, streams, transports, blueprints, RPC/module refs, DimOS `Spec` Protocols, adapter Protocols, skills/MCP exposure, CLI entry points, and generated registries involved. - - **Decisions**: Key choices with rationale and alternatives considered. - - **Safety / Simulation / Replay**: Hardware assumptions, sim/replay behavior, safety constraints, and manual QA surface. - - **Risks / Trade-offs**: Known risks and mitigations. - - **Migration / Rollout**: Compatibility, generated files, docs, and deployment steps. - - **Open Questions**: Outstanding decisions or unknowns. - - Reference proposal.md for intent and specs for behavior. Keep line-by-line work in tasks.md. - requires: - - proposal - - id: docs - generates: docs.md - description: Documentation impact plan for user, contributor, and coding-agent docs - template: docs.md - instruction: | - Create the documentation impact plan for the change. - - Sections: - - **User-Facing Docs**: Updates under `docs/usage/`, `docs/capabilities/`, `docs/platforms/`, or README files. - - **Contributor Docs**: Updates under `docs/development/`. - - **Coding-Agent Docs**: Updates under `docs/coding-agents/` or `AGENTS.md`. - - **Doc Validation**: Commands needed for changed docs, such as `doclinks`, `md-babel-py run `, and `bin/gen-diagrams`. - - **No Docs Needed**: If no docs are needed, explain why. - - Match `docs/development/writing_docs.md`: contributor-only docs belong in `docs/development`; user-facing behavior belongs in `docs/usage` or `docs/capabilities`. - requires: - - proposal - - id: tasks - generates: tasks.md - description: Implementation, validation, docs, and manual-QA checklist - template: tasks.md - instruction: | - Create the implementation checklist. The apply phase parses checkbox format, so every actionable task MUST use `- [ ]`. - - Guidelines: - - Group tasks under numbered `##` headings. - - Each task must be `- [ ] X.Y Task description`. - - Keep tasks small enough to complete in one focused session. - - Order tasks by dependency. - - Include docs and validation tasks from docs.md. - - Include generated registry tasks when blueprints or module registry inputs change. - - Include manual QA through the actual user surface: CLI, TUI, HTTP API, MCP tool, simulation/replay blueprint, hardware procedure, or library driver. - - When adding or revising tests, include tasks that make tests behavior-focused: setup the test, execute the functionality, and check the desired result. Avoid tasks that only construct objects and assert private fields, full metadata snapshots, default tables, or fake backend internals. - - For hardware or adapter tests, prefer public surfaces and observable effects. Do not require fake control-library hardware classes unless the fake is the only meaningful user-surface substitute; if a safety-critical backend detail must be asserted, name the safety behavior and keep the assertion minimal. - - Typical DimOS validation tasks: - - Run `openspec validate `. - - Run focused pytest targets for changed modules. - - Run `pytest dimos/robot/test_all_blueprints_generation.py` when blueprint registry output may change. - - Run docs validation commands for changed docs. - - Run lints/types when the touched area requires them. - - Reference specs for WHAT, design for HOW, and docs.md for documentation work. - requires: - - specs - - design - - docs -apply: - requires: - - tasks - tracks: tasks.md - instruction: | - Read proposal.md, specs, design.md, docs.md, and tasks.md before editing code. - Work through pending tasks, mark checkboxes complete as they finish, and keep artifacts current when implementation changes the plan. - When implementing tests, keep them behavior-focused: set up the test, execute functionality, and check the desired result. Do not add low-value tests that only construct objects and assert incidental details. - Verify with OpenSpec validation, focused tests, docs checks, and manual QA through the relevant DimOS surface. diff --git a/openspec/schemas/dimos-capability/templates/design.md b/openspec/schemas/dimos-capability/templates/design.md deleted file mode 100644 index 25031ceb8b..0000000000 --- a/openspec/schemas/dimos-capability/templates/design.md +++ /dev/null @@ -1,35 +0,0 @@ -## Context - - - -## Goals / Non-Goals - -**Goals:** - - -**Non-Goals:** - - -## DimOS Architecture - - - -## Decisions - - - -## Safety / Simulation / Replay - - - -## Risks / Trade-offs - - - -## Migration / Rollout - - - -## Open Questions - - diff --git a/openspec/schemas/dimos-capability/templates/docs.md b/openspec/schemas/dimos-capability/templates/docs.md deleted file mode 100644 index d274aed653..0000000000 --- a/openspec/schemas/dimos-capability/templates/docs.md +++ /dev/null @@ -1,19 +0,0 @@ -## User-Facing Docs - - - -## Contributor Docs - - - -## Coding-Agent Docs - - - -## Doc Validation - - - -## No Docs Needed - - diff --git a/openspec/schemas/dimos-capability/templates/proposal.md b/openspec/schemas/dimos-capability/templates/proposal.md deleted file mode 100644 index 98d409e8de..0000000000 --- a/openspec/schemas/dimos-capability/templates/proposal.md +++ /dev/null @@ -1,32 +0,0 @@ -## Why - - - -## What Changes - - - -## Affected DimOS Surfaces - - -- Modules/streams: -- Blueprints/CLI: -- Skills/MCP: -- Hardware/simulation/replay: -- Docs/generated registries: - -## Capabilities - -### New Capabilities - -- ``: - -### Modified Capabilities - -- ``: - -## Impact - - diff --git a/openspec/schemas/dimos-capability/templates/spec.md b/openspec/schemas/dimos-capability/templates/spec.md deleted file mode 100644 index afc0c1ff58..0000000000 --- a/openspec/schemas/dimos-capability/templates/spec.md +++ /dev/null @@ -1,16 +0,0 @@ -## ADDED Requirements - -### Requirement: - - -#### Scenario: -- **GIVEN** -- **WHEN** -- **THEN** -- **AND** - - diff --git a/openspec/schemas/dimos-capability/templates/tasks.md b/openspec/schemas/dimos-capability/templates/tasks.md deleted file mode 100644 index b38fcdfabb..0000000000 --- a/openspec/schemas/dimos-capability/templates/tasks.md +++ /dev/null @@ -1,15 +0,0 @@ -## 1. Implementation - -- [ ] 1.1 -- [ ] 1.2 - -## 2. Documentation - -- [ ] 2.1 - -## 3. Verification - -- [ ] 3.1 Run `openspec validate ` -- [ ] 3.2 Run focused tests for changed code -- [ ] 3.3 Run docs validation commands for changed docs -- [ ] 3.4 Manually QA through the relevant DimOS surface (CLI, MCP, simulation/replay, hardware procedure, HTTP API, or library driver) diff --git a/openspec/specs/behavior-focused-test-guidelines/spec.md b/openspec/specs/behavior-focused-test-guidelines/spec.md deleted file mode 100644 index 91fdbd7cec..0000000000 --- a/openspec/specs/behavior-focused-test-guidelines/spec.md +++ /dev/null @@ -1,32 +0,0 @@ -## Purpose - -Define coding-agent testing guidance that keeps unit tests focused on observable DimOS behavior rather than incidental implementation shape. - -## Requirements - -### Requirement: Behavior-focused unit test guidance - -DimOS SHALL provide coding-agent testing guidance that tells agents to write unit tests around observable behavior rather than incidental object shape. - -#### Scenario: Agent prepares to write a unit test -- **GIVEN** a coding agent is writing or reviewing a unit test -- **WHEN** it consults the DimOS coding-agent testing guidance -- **THEN** the guidance SHALL instruct it to structure the test as setup, execute functionality, and check the desired result -- **AND** the guidance SHALL discourage tests that only construct an object and assert many minor properties. - -#### Scenario: Agent reviews an over-assertive test -- **GIVEN** a proposed test asserts private fields, full metadata snapshots, or every backend command detail -- **WHEN** the agent applies the testing guidance -- **THEN** it SHALL either replace those assertions with a public behavior check or justify the smallest safety-critical assertion needed -- **AND** it SHALL prefer a test name that states the desired behavior being protected. - -### Requirement: Test-quality review checklist - -DimOS SHALL provide a concise checklist for coding agents to evaluate whether a unit test is behavior-focused. - -#### Scenario: Agent self-reviews a new test -- **GIVEN** a coding agent has written a new unit test -- **WHEN** it applies the checklist -- **THEN** the checklist SHALL ask what behavior would break if the test failed -- **AND** it SHALL ask whether the test executed functionality rather than only constructing an object -- **AND** it SHALL ask whether the assertions target public outcomes instead of private implementation details. diff --git a/openspec/specs/damiao-adapter-metadata-docs/spec.md b/openspec/specs/damiao-adapter-metadata-docs/spec.md deleted file mode 100644 index 63ca9ce74c..0000000000 --- a/openspec/specs/damiao-adapter-metadata-docs/spec.md +++ /dev/null @@ -1,31 +0,0 @@ -## Purpose - -Define documentation expectations for Damiao adapter metadata helpers. - -## Requirements - -### Requirement: Readable Damiao metadata documentation - -DimOS SHALL provide readable developer documentation for Damiao adapter metadata helpers so future maintainers can understand motor order, identifiers, limits, gains, and validation behavior. - -#### Scenario: Reading motor metadata helpers -- **GIVEN** a developer opens the Damiao metadata helper module -- **WHEN** they inspect the motor metadata type and receive-ID behavior -- **THEN** the documentation SHALL explain the meaning of joint order, motor type, send ID, receive ID, and default receive-ID derivation -- **AND** it MUST make clear that the metadata is used before hardware commands are sent. - -#### Scenario: Reading arm metadata helpers -- **GIVEN** a developer opens the Damiao arm metadata type -- **WHEN** they inspect arm limits, gains, gravity model fields, bus fields, or validation helpers -- **THEN** the documentation SHALL explain that these values describe explicit adapter-owned hardware metadata -- **AND** it SHALL distinguish metadata coercion and validation from runtime hardware communication. - -### Requirement: Damiao metadata validation readability - -DimOS SHALL keep Damiao metadata validation behavior understandable without requiring readers to infer intent from tests alone. - -#### Scenario: Understanding validation failures -- **GIVEN** an adapter provides duplicate IDs or mismatched vector lengths -- **WHEN** validation rejects the metadata -- **THEN** the helper documentation SHALL make the validation intent clear to developers -- **AND** validation errors MUST remain focused on preventing malformed metadata from reaching hardware command paths. diff --git a/openspec/specs/dm-motor-manipulator-adapters/spec.md b/openspec/specs/dm-motor-manipulator-adapters/spec.md deleted file mode 100644 index 2688e9cbf3..0000000000 --- a/openspec/specs/dm-motor-manipulator-adapters/spec.md +++ /dev/null @@ -1,50 +0,0 @@ -## Purpose - -Define the narrowed binding-backed OpenArm adapter behavior for the Rust-backed OpenArm RS path. - -## Requirements - -### Requirement: Binding-backed OpenArm adapter scope - -DimOS SHALL narrow the current binding-backed DMMotor/OpenArm behavior into an explicit OpenArm RS adapter path rather than presenting it as a generic DMMotor arm adapter. - -#### Scenario: Selecting the renamed binding-backed adapter -- **GIVEN** a hardware configuration needs the Rust-backed OpenArm binding path -- **WHEN** the configuration selects an adapter type -- **THEN** it SHALL use `openarm_rs` for the binding-backed OpenArm adapter -- **AND** it SHALL NOT rely on `dm_motor_arm` as the documented OpenArm binding-backed adapter key. - -#### Scenario: Avoiding generic Damiao defaults -- **GIVEN** a non-OpenArm Damiao arm needs support in the future -- **WHEN** a developer evaluates the OpenArm RS adapter -- **THEN** DimOS SHALL make clear that OpenArm RS metadata and defaults are OpenArm-specific -- **AND** DimOS MUST require a separate explicit adapter or change before treating those defaults as generic Damiao behavior. - -### Requirement: Binding-backed adapter safety behavior - -DimOS SHALL preserve the safety expectations already required for the binding-backed adapter while renaming and narrowing its surface. Tests for this behavior SHALL verify observable adapter outcomes and safety-relevant command effects rather than broad snapshots of private adapter construction state. - -#### Scenario: Coherent state reads -- **GIVEN** the OpenArm RS adapter is connected through the binding-backed path -- **WHEN** DimOS reads positions, velocities, and efforts during one coordinator cycle -- **THEN** the adapter SHALL provide a coherent state snapshot rather than independently loading the CAN bus for each read -- **AND** stale or malformed state SHALL be surfaced before unsafe commands are sent. - -#### Scenario: Gravity-compensation-only command -- **GIVEN** a user invokes a gravity-compensation-only validation path through the binding-backed adapter -- **WHEN** the adapter sends MIT commands for gravity compensation -- **THEN** the command SHALL use zero position stiffness so the arm remains manually movable -- **AND** the command SHALL use current measured state and configured OpenArm gravity metadata. - -#### Scenario: Missing binding remains selected-adapter scoped -- **GIVEN** the optional binding is not installed -- **WHEN** DimOS discovers or lists manipulator adapters without selecting OpenArm RS -- **THEN** discovery SHALL continue for adapters that do not require the binding -- **AND** missing-binding errors SHALL occur only when selecting or connecting the OpenArm RS path -- **AND** registry listing MUST NOT import the OpenArm RS implementation only to discover its adapter key. - -#### Scenario: Safety tests remain behavior-focused -- **GIVEN** a test protects binding-backed adapter safety behavior -- **WHEN** the test verifies command output or state behavior -- **THEN** it SHALL name the safety behavior being protected -- **AND** it SHALL avoid asserting unrelated private fields or full default tables that are not required to prove that behavior. diff --git a/openspec/specs/manipulator-adapter-discovery/spec.md b/openspec/specs/manipulator-adapter-discovery/spec.md deleted file mode 100644 index 1cd1a41dfb..0000000000 --- a/openspec/specs/manipulator-adapter-discovery/spec.md +++ /dev/null @@ -1,59 +0,0 @@ -## Purpose - -Define lazy manipulator adapter discovery behavior so adapter keys remain listable in partial installations without importing unselected hardware SDKs. - -## Requirements - -### Requirement: Metadata-only manipulator adapter listing - -DimOS SHALL list registered manipulator adapter keys without importing unselected adapter implementation modules. Tests for this requirement SHALL verify discovery behavior through registry listing and module-import observability rather than asserting incidental registry object internals. - -#### Scenario: Listing adapters in a partial installation -- **GIVEN** a DimOS environment where one or more optional manipulator hardware SDKs are not installed -- **WHEN** a user imports the manipulator adapter registry and asks for available adapter keys -- **THEN** DimOS SHALL return the known adapter keys whose lightweight registry metadata is available -- **AND** DimOS MUST NOT import unselected adapter implementation modules only to produce that list. - -#### Scenario: Unrelated adapter remains discoverable -- **GIVEN** an optional binding required by one manipulator adapter is not importable -- **WHEN** a user lists manipulator adapters without selecting that adapter -- **THEN** DimOS SHALL continue discovering unrelated manipulator adapters -- **AND** missing optional dependency errors SHALL NOT prevent unrelated adapter keys from appearing. - -### Requirement: Selected manipulator adapter loading - -DimOS SHALL import and instantiate only the manipulator adapter selected by `adapter_registry.create(name, **kwargs)`. Tests for selected loading SHALL exercise the create call and observe the selected adapter result or actionable error. - -#### Scenario: Creating a selected adapter -- **GIVEN** a registered manipulator adapter key and constructor arguments for that adapter -- **WHEN** a user calls `adapter_registry.create()` with that key -- **THEN** DimOS SHALL resolve the selected adapter implementation -- **AND** DimOS SHALL instantiate the selected adapter with the provided arguments. - -#### Scenario: Unknown adapter name -- **GIVEN** a manipulator adapter key is not registered -- **WHEN** a user calls `adapter_registry.create()` with that key -- **THEN** DimOS SHALL fail with an error that identifies the unknown adapter -- **AND** the error SHALL include the currently available adapter keys. - -#### Scenario: Broken selected adapter registration -- **GIVEN** a manipulator adapter key is registered to an implementation path that cannot be resolved -- **WHEN** a user selects that adapter key -- **THEN** DimOS SHALL fail with an actionable selected-adapter error -- **AND** the error SHALL identify the selected adapter key or its configured implementation path. - -#### Scenario: Registry tests remain behavior-focused -- **GIVEN** a test covers manipulator adapter discovery or selected loading -- **WHEN** it verifies the registry behavior -- **THEN** it SHALL observe available keys, selected instantiation, import side effects, or actionable errors -- **AND** it SHALL avoid asserting registry internals that are not part of the developer-visible contract. - -### Requirement: Stable manipulator adapter keys - -DimOS SHALL preserve existing built-in manipulator adapter keys across the lazy discovery migration. - -#### Scenario: Existing adapter keys remain available -- **GIVEN** a user has a hardware configuration or blueprint that selects an existing built-in manipulator adapter key -- **WHEN** DimOS discovers manipulator adapters after this change -- **THEN** that key SHALL remain available if its lightweight registry metadata is present -- **AND** users SHALL NOT need to rename existing manipulator adapter selections for this registry migration. diff --git a/openspec/specs/openarm-adapter-compatibility/spec.md b/openspec/specs/openarm-adapter-compatibility/spec.md deleted file mode 100644 index 731e82c777..0000000000 --- a/openspec/specs/openarm-adapter-compatibility/spec.md +++ /dev/null @@ -1,43 +0,0 @@ -## Purpose - -Define compatibility and safety expectations for the stable in-tree OpenArm adapter path. - -## Requirements - -### Requirement: OpenArm adapter compatibility - -DimOS SHALL preserve the existing OpenArm adapter selection and observable behavior while keeping the binding-backed OpenArm RS path separate. - -#### Scenario: Selecting the OpenArm adapter -- **GIVEN** an existing hardware configuration selects the OpenArm adapter -- **WHEN** DimOS initializes the manipulator hardware -- **THEN** DimOS SHALL continue to create an OpenArm-specific adapter through the existing `openarm` adapter selection surface -- **AND** the adapter SHALL preserve OpenArm-specific side, joint, motor, limit, gain, gravity-model, and in-tree CAN-driver behavior. - -#### Scenario: Existing OpenArm blueprints -- **GIVEN** an existing OpenArm blueprint selects the current OpenArm adapter path -- **WHEN** this cleanup is present -- **THEN** the blueprint SHALL continue selecting the `openarm` adapter unless it is explicitly changed -- **AND** existing stable OpenArm runnable blueprint names SHALL remain stable unless a generated-registry update intentionally changes only binding-backed OpenArm RS names. - -#### Scenario: OpenArm source-level stability -- **GIVEN** the original OpenArm adapter is the stable hardware path -- **WHEN** the binding-backed OpenArm RS path is renamed or refactored -- **THEN** DimOS SHALL NOT require the original OpenArm adapter to inherit binding-backed or shared Damiao runtime behavior -- **AND** any source-level OpenArm adapter changes MUST be limited to preserving existing behavior or undoing unintended refactor drift. - -### Requirement: OpenArm hardware safety preservation - -OpenArm adapter behavior SHALL remain at least as safe as the pre-cleanup behavior for enablement, command writes, gravity compensation, and shutdown. - -#### Scenario: Stopping or disconnecting OpenArm hardware -- **GIVEN** OpenArm motors are enabled through DimOS -- **WHEN** the adapter is stopped or disconnected -- **THEN** DimOS SHALL attempt to disable or stop commanding the motors through the adapter -- **AND** the cleanup SHALL NOT introduce a continued background command loop after disconnect. - -#### Scenario: OpenArm gravity compensation -- **GIVEN** OpenArm gravity compensation is enabled -- **WHEN** DimOS computes and sends supported OpenArm commands -- **THEN** gravity feed-forward SHALL use the OpenArm-specific model and current measured joint state -- **AND** invalid or stale state SHALL prevent unsafe gravity-compensation commands from being sent. diff --git a/openspec/specs/openarm-rs-adapter-selection/spec.md b/openspec/specs/openarm-rs-adapter-selection/spec.md deleted file mode 100644 index 1bb0e9fbe6..0000000000 --- a/openspec/specs/openarm-rs-adapter-selection/spec.md +++ /dev/null @@ -1,48 +0,0 @@ -## Purpose - -Define the explicit user-facing selection and safety staging surface for the OpenArm RS adapter. - -## Requirements - -### Requirement: Explicit OpenArm RS adapter selection - -DimOS SHALL provide an explicit Rust-backed OpenArm adapter selection surface named `openarm_rs` for users who choose the binding-backed OpenArm path. Unit tests for the OpenArm RS adapter SHALL avoid fake `can_motor_control` hardware behavior and cover only constructor-time public metadata and validation behavior. - -#### Scenario: Selecting the Rust-backed OpenArm path -- **GIVEN** a hardware configuration selects the binding-backed OpenArm adapter -- **WHEN** DimOS initializes manipulator hardware through the adapter registry -- **THEN** the configuration SHALL select the adapter using the `openarm_rs` key -- **AND** the selected adapter SHALL represent OpenArm hardware rather than a generic Damiao arm. - -#### Scenario: Binding unavailable for OpenArm RS -- **GIVEN** the `can_motor_control` binding is not importable in the runtime environment -- **WHEN** a user selects the `openarm_rs` adapter -- **THEN** DimOS SHALL fail with a clear selected-adapter missing-binding error -- **AND** DimOS MUST continue discovering unrelated manipulator adapters that do not require that binding -- **AND** listing manipulator adapters MUST NOT import the OpenArm RS implementation only to discover its adapter key. - -### Requirement: OpenArm RS blueprint naming - -DimOS SHALL expose binding-backed OpenArm runnable blueprints with names that identify the OpenArm RS path. - -#### Scenario: Listing binding-backed OpenArm blueprints -- **GIVEN** binding-backed OpenArm blueprints are exported -- **WHEN** a user lists or runs OpenArm blueprints through the DimOS CLI -- **THEN** runnable names SHALL use `openarm-rs` wording instead of `dm-motor-openarm` wording -- **AND** documentation SHALL describe those blueprints as opt-in alternatives to the stable `openarm` path. - -### Requirement: OpenArm RS safety staging - -DimOS SHALL document and preserve staged validation expectations for the OpenArm RS adapter path. Unit tests for this path SHALL NOT simulate backend control-library effects with fake hardware classes. - -#### Scenario: Validating before real hardware operation -- **GIVEN** a user wants to run OpenArm hardware through the `openarm_rs` path -- **WHEN** they follow DimOS bring-up guidance -- **THEN** the guidance SHALL start with binding availability and mock or virtual CAN validation -- **AND** it SHALL treat real hardware gravity compensation and trajectory execution as later staged QA steps. - -#### Scenario: OpenArm RS tests remain behavior-focused -- **GIVEN** a test covers OpenArm RS adapter behavior -- **WHEN** it verifies behavior without the real control binding -- **THEN** it SHALL limit coverage to constructor-time metadata, limits, registration, and validation errors -- **AND** it SHALL avoid fake `can_motor_control` robots, buses, arms, state reads, and command effects. From 2125808fe070c7786b31825d15d1229cb8c01e0a Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 8 Jun 2026 17:37:33 -0700 Subject: [PATCH 032/110] fix: openarm adapter lifecycle --- .../manipulators/damiao/base_adapter.py | 21 +++++ .../manipulators/damiao/test_base_adapter.py | 94 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py index ecd3ad823c..35491f6956 100644 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ b/dimos/hardware/manipulators/damiao/base_adapter.py @@ -456,6 +456,17 @@ def write_stop(self) -> bool: try: q_now = self.read_joint_positions() except RuntimeError: + try: + self._robot.disable() + except Exception: + logger.warning( + "damiao adapter stop disable failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + exc_info=True, + ) + else: + self._enabled = False return False return self.write_mit_commands( q=q_now, @@ -499,6 +510,16 @@ def write_enable(self, enable: bool) -> bool: adapter=type(self).__name__, hardware_id=self._hardware_id, ) + self._enabled = False + try: + self._robot.disable() + except Exception: + logger.warning( + "damiao adapter startup hold disable failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + exc_info=True, + ) return False return True diff --git a/dimos/hardware/manipulators/damiao/test_base_adapter.py b/dimos/hardware/manipulators/damiao/test_base_adapter.py index 516e4ec93e..ccf4a7de69 100644 --- a/dimos/hardware/manipulators/damiao/test_base_adapter.py +++ b/dimos/hardware/manipulators/damiao/test_base_adapter.py @@ -21,6 +21,18 @@ from dimos.hardware.manipulators.spec import ControlMode +class _FakeRobot: + def __init__(self) -> None: + self.enable_calls: int = 0 + self.disable_calls: int = 0 + + def enable(self) -> None: + self.enable_calls += 1 + + def disable(self) -> None: + self.disable_calls += 1 + + def _arm_spec() -> DamiaoArmSpec: return DamiaoArmSpec( name="test_damiao", @@ -39,6 +51,20 @@ def _arm_spec() -> DamiaoArmSpec: ) +def _attach_robot( + adapter: DamiaoArmAdapterBase, + robot: _FakeRobot, + *, + arm: object | None = None, + enabled: bool | None = None, +) -> None: + adapter._robot = robot + if arm is not None: + adapter._arm = arm + if enabled is not None: + adapter._enabled = enabled + + def test_arm_spec_exposes_joint_order_for_backend_commands() -> None: spec = _arm_spec() @@ -92,3 +118,71 @@ def test_base_adapter_reports_limits_and_accepts_supported_mode() -> None: assert adapter.set_control_mode(ControlMode.TORQUE) is True assert adapter.get_control_mode() == ControlMode.TORQUE assert adapter.set_control_mode(ControlMode.VELOCITY) is False + + +def test_write_stop_gravity_comp_holds_current_position(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec(), gravity_comp=True) + robot = _FakeRobot() + _attach_robot(adapter, robot, arm=object(), enabled=True) + captured_commands: dict[str, list[float]] = {} + + def read_positions() -> list[float]: + return [0.1, -0.2] + + def write_mit_commands( + *, q: list[float], dq: list[float], kp: list[float], kd: list[float], tau: list[float] + ) -> bool: + captured_commands.update(q=q, dq=dq, kp=kp, kd=kd, tau=tau) + return True + + monkeypatch.setattr(adapter, "read_joint_positions", read_positions) + monkeypatch.setattr(adapter, "write_mit_commands", write_mit_commands) + + assert adapter.write_stop() is True + assert captured_commands == { + "q": [0.1, -0.2], + "dq": [0.0, 0.0], + "kp": [5.0, 6.0], + "kd": [0.1, 0.2], + "tau": [0.0, 0.0], + } + assert robot.disable_calls == 0 + + +def test_write_stop_gravity_comp_read_failure_disables_robot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec(), gravity_comp=True) + robot = _FakeRobot() + _attach_robot(adapter, robot, arm=object(), enabled=True) + + def read_positions() -> list[float]: + raise RuntimeError("state unavailable") + + monkeypatch.setattr(adapter, "read_joint_positions", read_positions) + + assert adapter.write_stop() is False + assert robot.disable_calls == 1 + assert adapter.read_enabled() is False + + +def test_write_enable_startup_hold_failure_disables_robot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec(), gravity_comp=True) + robot = _FakeRobot() + _attach_robot(adapter, robot) + + def read_positions() -> list[float]: + return [0.1, -0.2] + + def reject_hold(_positions: list[float], _velocity: float = 1.0) -> bool: + return False + + monkeypatch.setattr(adapter, "read_joint_positions", read_positions) + monkeypatch.setattr(adapter, "write_joint_positions", reject_hold) + + assert adapter.write_enable(True) is False + assert robot.enable_calls == 1 + assert robot.disable_calls == 1 + assert adapter.read_enabled() is False From d92a78ba757fe49a07321b69eb03a00ae0876ea4 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 13 Jun 2026 11:13:23 -0700 Subject: [PATCH 033/110] spec: planning group --- CONTEXT.md | 61 ++ .../add-planning-groups/.openspec.yaml | 2 + .../changes/add-planning-groups/design.md | 555 ++++++++++++++++++ .../changes/add-planning-groups/proposal.md | 53 ++ 4 files changed, 671 insertions(+) create mode 100644 CONTEXT.md create mode 100644 openspec/changes/add-planning-groups/.openspec.yaml create mode 100644 openspec/changes/add-planning-groups/design.md create mode 100644 openspec/changes/add-planning-groups/proposal.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..6597467a7f --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,61 @@ +# DimOS Robotics Language + +Shared vocabulary for DimOS robotics concepts. These terms define domain language, not implementation details. + +## Language + +**Planning Group**: +A named selectable serial kinematic chain of robot joints used as the unit of manipulation planning. A planning group is defined by its chain/joints, not by end-effector metadata. +_Avoid_: Move group, movegroup + +**Planning Group Definition**: +The model-level declaration of a planning group before it is bound to a concrete robot in a planning world. +_Avoid_: Runtime group, robot ID + +**End-Effector Association**: +Separate metadata used for pose-targeted operations. For a planning group defined by a chain, the end-effector link is the chain tip. For a planning group defined only by joints, there is no end-effector link. +_Avoid_: Planning group definition + +**Resolved Planning Group**: +A planning group after model-level declarations have been bound to a concrete robot, namespace, and planning world. +_Avoid_: Planning group config, robot ID + +**Planning Group Selection**: +The set of one or more planning groups chosen for a planning request. +_Avoid_: Composite group + +**Auxiliary Planning Group**: +A planning group selected to participate in a specific planning request without receiving a direct end-effector pose constraint in that request. A planning group may be auxiliary in one request and directly targeted in another. +_Avoid_: Joint-only group, intrinsic auxiliary group + +**Coordinated Planning Problem**: +A planning request over one or more selected planning groups that is solved as one combined joint-space problem with one synchronized result. +_Avoid_: Batch planning, independent planning + +**Planning Group ID**: +An API-level identifier for a planning group, always namespaced as `{robot_name}/{group_name}`. +_Avoid_: Bare group name, robot ID + +**Planning Group Descriptor**: +A read-only snapshot returned by query APIs that describes an available planning group and may be used ergonomically as a planning group selector. +_Avoid_: Live planning group handle, resolved planning group + +**Joint State**: +A resolved-joint-name-keyed robot state that can represent any set of joints and is not inherently coupled to a robot, planning group, or planning group selection. +_Avoid_: Planning-group-scoped state + +**Robot Model Joint Names**: +The objective set of controllable joints exposed by a robot coordinator for state and command. This usually aligns with the model's actuated joints, but is not itself a planning group. +_Avoid_: Implicit planning group + +**Local Model Joint Name**: +A joint name as it appears inside a robot model or SRDF before the model is bound to a concrete robot in a planning world. +_Avoid_: Runtime joint name, coordinator joint name + +**Resolved Joint Name**: +A world-level joint name exposed above the model parsing layer, always namespaced as `{robot_name}/{local_joint_name}` so it is stable and unique within a planning world. +_Avoid_: Bare joint name, local joint name + +**Robot Placement**: +The placement of a robot model within the planning world. Robot placement belongs in the robot model description rather than in a separate planning configuration transform. +_Avoid_: Planning base pose, config placement transform diff --git a/openspec/changes/add-planning-groups/.openspec.yaml b/openspec/changes/add-planning-groups/.openspec.yaml new file mode 100644 index 0000000000..494ca38369 --- /dev/null +++ b/openspec/changes/add-planning-groups/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-13 diff --git a/openspec/changes/add-planning-groups/design.md b/openspec/changes/add-planning-groups/design.md new file mode 100644 index 0000000000..3f24a3069b --- /dev/null +++ b/openspec/changes/add-planning-groups/design.md @@ -0,0 +1,555 @@ +# Planning Groups Design + +## 1. Summary + +This change makes **Planning Group** a first-class manipulation planning concept in DimOS. + +Today, manipulation planning is centered on robot identity: planner and IK interfaces select a `WorldRobotID`, while `RobotModelConfig` carries one `joint_names`, one `base_link`, and one `end_effector_link`. That shape works for a single serial arm, but it conflates robot/model identity with the kinematic subset being planned. + +The new design separates those concerns: + +- Robot identity describes the hardware/model instance. +- Planning group identity describes the selectable kinematic planning unit. +- SRDF `` declarations are the primary source of planning groups. +- Existing single-chain robots without SRDF can continue through conservative fallback generation of `{robot_name}/manipulator`. +- Planning and IK APIs select planning groups, not robot IDs. +- Pose planning supports request-scoped auxiliary groups, such as torso/waist joints that contribute free DOFs without direct pose constraints. +- Generated plans store only selected group IDs and one synchronized resolved-joint path. +- Preview and execution project from that generated plan lazily. + +The design also standardizes public joint naming above model parsing: all public joint states and paths use resolved joint names of the form `{robot_name}/{local_joint_name}`. + +## 2. Motivation + +Current manipulation planning treats a robot instance as the planning unit. `PlannerSpec.plan_joint_path(...)`, `KinematicsSpec.solve(...)`, and several `WorldSpec` methods all center on `WorldRobotID`. `ManipulationModule` also stores planned paths and trajectories by `RobotName`. + +That makes several important cases awkward: + +- Arm-only planning versus torso+arm planning. +- Coordinated dual-arm planning. +- Future multi-robot coordinated planning. +- Robots with multiple selectable serial chains. +- Explicit distinction between controllable joints and planning groups. + +The current `RobotModelConfig` fields also hide planning-group semantics. A single `end_effector_link` and `base_link` imply one planning chain per robot config. A single `joint_names` list currently acts like a hidden planning group, while also serving as the controllable/coordinator joint set. + +Finally, local URDF joint names are ambiguous once multiple robots or arms with repeated names participate in one plan. Public state/path APIs need stable resolved names so a path can represent coordinated multi-group or multi-robot motion without relying on external robot scoping. + +## 3. Goals and Non-Goals + +### Goals + +- Make planning groups first-class planning units. +- Use SRDF `` declarations as the primary source of planning group definitions. +- Provide conservative fallback generation for current single-chain arms without SRDF. +- Require explicit planning group selection for planning and IK. +- Support coordinated planning over one or more selected planning groups. +- Support pose planning with request-scoped auxiliary groups that contribute free DOFs. +- Expose stable resolved joint names above model/SRDF parsing. +- Add group-aware IK and planner interfaces. +- Replace robot-scoped end-effector FK/Jacobian queries with group-scoped APIs. +- Store minimal generated plan artifacts and project lazily for preview/execution. +- Keep existing trajectory controllers unaware of planning groups. + +### Non-Goals + +- Full MoveIt SRDF support. +- First-class composite or nested planning groups. +- Mixed pose+joint target planning in one request. +- Atomic multi-task trajectory batch dispatch. +- Rollback-on-rejection for multi-task trajectory dispatch. +- Planning config placement transforms such as `base_pose`. +- Silent compatibility mode that treats old `joint_names` as an implicit planning group without validation. +- Making controllers, coordinator tasks, or hardware drivers planning-group-aware. + +## 4. Domain Model + +Root `CONTEXT.md` contains the canonical glossary for this change. The core design shape is: + +```text +PlanningGroupDefinition + model-level declaration from SRDF or fallback generation + ↓ bind to concrete robot/world +ResolvedPlanningGroup + runtime/world-bound group with resolved joints and link frames + ↓ selected by request +PlanningGroupSelection + one or more non-overlapping resolved groups + ↓ IK/planner solve +GeneratedPlan + selected group IDs + synchronized resolved-joint path +``` + +Important terms: + +- **Planning Group**: named selectable serial kinematic chain of robot joints used as the manipulation planning unit. +- **Planning Group Definition**: model-level declaration before binding to a concrete robot/world. +- **Resolved Planning Group**: runtime/world-bound group with concrete robot identity, namespace, resolved joints, and frame data. +- **Planning Group Selection**: one or more planning groups chosen for a planning request. +- **Auxiliary Planning Group**: group selected in a specific pose-planning request without receiving a direct pose constraint in that request. +- **Planning Group ID**: public identifier, always `{robot_name}/{group_name}`. +- **Planning Group Descriptor**: immutable query snapshot describing an available planning group; not a live handle. +- **Local Model Joint Name**: name inside URDF/SRDF, such as `joint1`. +- **Resolved Joint Name**: public world-level name, always `{robot_name}/{local_joint_name}`. + +Identifier layering: + +```text +robot_name Stable robot/model instance name. +WorldRobotID Internal runtime world handle only. +PlanningGroupID {robot_name}/{group_name} +Local joint name joint1 +Resolved joint name {robot_name}/{local_joint_name} +Coordinator name Hardware/control boundary name; default identity with resolved name. +``` + +`WorldRobotID` must not appear in public state, path, generated plan, or planning group selection APIs. + +## 5. Planning Group Sources + +Planning group definitions are discovered in this precedence order: + +1. Explicit `srdf_path` on robot/model config. +2. Conservative SRDF auto-discovery with visible warning. +3. Fallback single-chain generation. +4. Error if no supported SRDF groups exist and fallback cannot infer exactly one unambiguous serial chain. + +Supported SRDF forms for this change: + +```xml + + + +``` + +```xml + + + + + +``` + +Unsupported forms are skipped with warnings: + +- `` groups. +- Nested `` references. +- Mixed link/joint/chain forms. +- Branching, disconnected, unordered, or otherwise non-serial groups. +- SRDF `` metadata. + +If a caller later selects a skipped group, resolution fails as unknown or unsupported. + +## 6. Fallback Group Generation + +When no SRDF is available, DimOS may generate exactly one planning group: + +```text +group name: manipulator +group ID: {robot_name}/manipulator +``` + +Fallback rules: + +- Use `RobotModelConfig.joint_names` as the candidate controllable set. +- Validate that candidate joints form one unambiguous serial chain in the parsed model. +- Allow prismatic joints inside the serial chain. +- Exclude only terminal/tip prismatic joints when they are likely finger/gripper joints. +- Set fallback pose target tip to the last controlled chain link. +- Error for ambiguous, branching, disconnected, or multi-chain models; such robots require SRDF. + +This preserves current single serial arm behavior without pretending every robot's `joint_names` list is a valid planning group. + +## 7. End-Effector Semantics + +A planning group is defined by chain/joints, not by SRDF `` metadata. This change ignores SRDF `` entirely. + +Rules: + +- Chain-defined group: pose target frame is the chain `tip_link`. +- Explicit joint-list group: may have a pose target frame only if validation proves it is one serial chain with a unique tip. +- Group with no valid tip: may participate in joint planning or as an auxiliary group, but cannot be directly pose-targeted. + +Pose-targeted APIs validate only targeted groups. Auxiliary groups do not need to be pose-ineligible; auxiliary status is request-scoped. A group may have a tip and still be auxiliary in a particular request. + +## 8. Public Planning APIs + +Representative API shape for pose targets: + +```python +plan_to_poses( + pose_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, PoseStamped], + *, + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroupDescriptor] = (), +) -> GeneratedPlan +``` + +Meaning: + +- `pose_targets` are selected groups with end-effector pose constraints in this request. +- `auxiliary_groups` are selected groups with no pose constraint in this request. +- Effective selection is `pose_targets.keys() ∪ auxiliary_groups`. +- Auxiliary groups are free DOFs for IK and planning. +- Mixed pose+joint targets are not supported in this change. + +Example: + +```python +plan_to_poses( + pose_targets={"robot/arm": target_pose}, + auxiliary_groups=["robot/torso"], +) +``` + +This plans one coordinated problem over arm and torso joints. The arm tip must reach `target_pose`; torso joints are free to move as needed. + +Representative API shape for joint targets: + +```python +plan_to_joint_targets( + joint_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, JointState], +) -> GeneratedPlan +``` + +Rules: + +- Joint targets are keyed by planning group at the API boundary. +- Each group's joint target keys must exactly match that group's selected resolved joints. +- Internally the request lowers to one ordered combined resolved-joint start/goal problem. + +Planning APIs may accept either a Planning Group ID string or a Planning Group Descriptor. Descriptors are ergonomic immutable snapshots. APIs normalize descriptors by extracting their ID and re-resolving current runtime group data. + +## 9. Spec Interface Changes + +This section refers to DimOS Python `Spec` Protocols, not OpenSpec behavior specs. + +### WorldSpec + +`WorldSpec` owns planning group listing and resolution: + +```python +list_planning_groups() -> Sequence[PlanningGroupDescriptor] +resolve_planning_groups(group_ids: Sequence[PlanningGroupID]) -> Sequence[ResolvedPlanningGroup] +``` + +Resolution responsibilities: + +- Validate IDs are known. +- Bind model-level definitions to concrete robot/world data. +- Convert local model joint names to resolved joint names. +- Detect overlapping resolved joints across selected groups and fail. +- Return enough resolved data for IK/planner/backend internals to map back to local names and model instances. + +Group-scoped query APIs replace robot-scoped end-effector APIs: + +```python +get_group_pose(ctx, group_id) -> PoseStamped +get_group_jacobian(ctx, group_id) -> ... +``` + +These are valid only for groups with a pose target frame. `get_link_pose(ctx, robot_id, link_name)` remains as a lower-level query. + +### KinematicsSpec + +IK must solve over the full effective planning group selection: + +```python +solve_pose_targets( + world: WorldSpec, + pose_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, PoseStamped], + *, + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroupDescriptor] = (), + seed: JointState | None = None, + tolerances: PoseTolerance | None = None, + check_collision: bool = True, + max_attempts: int = 1, +) -> IKResult +``` + +IK constraints apply only to `pose_targets`. Auxiliary group joints are decision variables with no direct pose constraints. `IKResult.solution` contains exactly the selected resolved joints, not full robot/world state. + +### PlannerSpec + +Planner APIs operate over selected groups / resolved selected joints rather than a single `robot_id`. + +For joint planning: + +- Start and goal `JointState` keys must exactly equal selected resolved joints. +- Missing keys, extra keys, or partial goals fail early. + +For pose planning: + +- IK returns a selected-joint goal. +- The planner solves one combined joint-space problem from selected-joint start to selected-joint goal. + +Backends may return or raise `UNSUPPORTED` for backend limitations, including cross-robot coordinated planning. The public interface permits multi-robot selections. + +## 10. State, Naming, and Exactness Rules + +Above the model/SRDF parsing layer, all joint states use resolved joint names: + +```text +{robot_name}/{local_joint_name} +``` + +Examples: + +```text +left/joint1 +right/joint1 +a750/joint3 +``` + +Local names remain inside URDF/SRDF parsing and backend internals. Backends may strip the robot namespace and use `(local_joint_name, model_instance)` internally. + +Exactness rules: + +- Joint-space start keys must exactly equal selected resolved joints. +- Joint-space goal keys must exactly equal selected resolved joints. +- No missing selected joints. +- No extra joints. +- No partial targets. +- `IKResult.solution.keys()` equals selected resolved joints. +- `PlanningResult.path[i].keys()` equals selected resolved joints for every waypoint. + +These rules prevent silently planning for the wrong group or ignoring caller-supplied joints. + +## 11. Generated Plan Model + +`GeneratedPlan` is the canonical planning artifact: + +```python +GeneratedPlan: + group_ids: tuple[PlanningGroupID, ...] + path: list[JointState] + status: PlanningStatus + planning_time: float | None + path_length: float | None + iterations: int | None + message: str +``` + +`GeneratedPlan.path[i]` contains exactly the selected resolved joints for every waypoint. + +The plan does not store: + +- Per-robot paths. +- Per-task trajectories. +- Preview samples. +- Live world/planner handles. +- Controller-specific execution plans. + +`ManipulationModule` may keep `_last_plan: GeneratedPlan | None` as convenience state, but the returned plan object is canonical. + +## 12. Preview and Execution Projection + +Preview and execution project lazily from `GeneratedPlan`. + +### Preview + +`preview_plan(plan)`: + +- Uses the selected resolved-joint path. +- Projects into visualization/world monitor data as needed. +- Does not require execution-specific trajectory precomputation. + +### Execution + +`execute_plan(plan)`: + +1. Group resolved joint names by robot namespace/coordinator task. +2. Project the combined path into one `JointTrajectory` per affected trajectory task. +3. Order trajectory positions according to the task's configured joint order. +4. Convert resolved joint names to coordinator joint names if a boundary mapping exists. +5. Invoke each trajectory task. + +The current coordinator/JTC architecture already treats task invocation as asynchronous dispatch. JTCs run concurrently in the coordinator tick loop. This change does not add atomic batch dispatch, rollback-on-rejection, or a new execution batch abstraction. + +Planning groups do not enter the controller layer. Controllers consume joint-name-keyed trajectories. + +## 13. Migration Plan + +Configuration changes: + +- Add `srdf_path` to `RobotConfig`. +- Add `srdf_path` to `RobotModelConfig`. +- Store parsed planning group definitions on `RobotModelConfig`. +- Keep `RobotConfig.joint_names` and `RobotModelConfig.joint_names`, but define them as the controllable/coordinator joint set, not a planning group. + +Fields removed or deprecated under the new design: + +- `RobotConfig.base_link` +- `RobotConfig.base_pose` +- `RobotModelConfig.base_link` +- `RobotModelConfig.base_pose` +- `RobotModelConfig.end_effector_link` + +Implementation rollout: + +1. Add planning group data model and SRDF/fallback extraction. +2. Add deterministic local/resolved joint-name helpers. +3. Update `WorldSpec` and Drake world to list and resolve planning groups. +4. Add group-scoped pose/Jacobian APIs. +5. Update `KinematicsSpec` and IK implementation to solve selected pose targets plus auxiliary free groups. +6. Update `PlannerSpec` and planner implementation to operate over selected resolved joints. +7. Replace `ManipulationModule` robot-keyed planned path/trajectory caches with `GeneratedPlan` and optional `_last_plan`. +8. Update preview and execution projection. +9. Update manipulation skills/wrappers and documentation. +10. Update existing robot configs; rely on fallback for current single serial arms unless SRDF is added. + +Generated registry updates are not expected unless implementation changes blueprint names or adds/removes blueprint module-level variables. If that happens, run: + +```bash +pytest dimos/robot/test_all_blueprints_generation.py +``` + +## 14. Validation and Errors + +Validation should fail clearly for: + +- Unknown planning group ID. +- Unsupported selected SRDF group form. +- Fallback cannot infer one serial chain. +- Selected planning groups overlap in resolved joints. +- Pose-targeted group has no valid tip/pose target frame. +- Joint target keys do not exactly match selected group joints. +- Start/goal joint states contain missing or extra selected joints. +- Backend does not support the requested coordinated planning problem. +- Cross-robot coordinated planning requested on a backend that cannot support it. + +Warnings should be emitted for: + +- SRDF auto-discovery. +- Unsupported SRDF groups skipped during parsing. + +Manual QA should cover: + +- Single-arm no-SRDF fallback planning. +- SRDF chain group planning. +- SRDF joint-list group planning. +- Arm+torso pose planning where torso is auxiliary/free. +- Multi-group result shape and no-overlap validation. +- Multi-task execution projection with distinct joint namespaces. + +## 15. Alternatives Considered + +- **Composite groups as first-class objects**: rejected. Selecting multiple groups per request is enough and avoids duplicate group modeling. +- **Bare group names**: rejected. Planning Group IDs are always namespaced as `{robot_name}/{group_name}`. +- **Robot-scoped planning API**: rejected. Robot identity and planning group selection are separate concerns. +- **Full SRDF support**: deferred. This change supports only serial chain and ordered joint-list groups. +- **Parsing SRDF ``**: rejected for this change. Group pose target frame comes from chain/joint validation, not end-effector metadata. +- **Implicit default planning group**: rejected. Planning group selection is required. +- **Treating old `joint_names` as a compatibility planning group**: rejected unless it validates through conservative fallback. +- **`include_groups` with implicit semantics**: rejected in favor of request-scoped `auxiliary_groups`. +- **Enforcing auxiliary groups to be joint-only/no-EEF**: rejected. Auxiliary status belongs to the request, not the group definition. +- **Mixed pose+joint constraints now**: deferred. +- **Precomputed execution plans**: rejected. Generated plans stay minimal; downstream projections happen lazily. +- **Atomic multi-task trajectory dispatch**: deferred. Existing task invocation is acceptable for now. + +## 16. Rollout / Implementation Phases + +Suggested implementation phases: + +1. **Data model and parsing** + - Add planning group definition/descriptor/resolved-group types. + - Add SRDF path fields. + - Implement supported SRDF group parsing. + - Implement fallback generation. + +2. **World resolution** + - Add `WorldSpec.list_planning_groups()` and `resolve_planning_groups(...)`. + - Update Drake world to bind group definitions to model instances and resolved names. + - Add overlap validation. + +3. **Group-scoped kinematics queries** + - Add `get_group_pose(...)` and `get_group_jacobian(...)`. + - Remove/deprecate robot-scoped end-effector queries. + +4. **IK and planner interfaces** + - Update IK to accept pose targets plus auxiliary groups. + - Update planner to operate over selected resolved joints. + - Enforce exact start/goal key rules. + +5. **Generated plan flow** + - Add `GeneratedPlan`. + - Replace robot-keyed planned path/trajectory caches. + - Store only optional `_last_plan` convenience state. + +6. **Preview and execution projection** + - Project generated plans to visualization as needed. + - Project generated plans to per-task `JointTrajectory` at execution time. + +7. **Robot config migration and docs** + - Update existing robot configs. + - Add docs for SRDF support, fallback generation, APIs, and naming rules. + - Update manipulation skills/wrappers. + +## 17. Safety / Simulation / Replay + +This is primarily a planning API and modeling change. It should not change low-level trajectory controller semantics. + +Hardware-facing assumptions: + +- Execution still sends `JointTrajectory` messages to existing coordinator trajectory tasks. +- Affected trajectory tasks control disjoint resolved/coordinator joint sets. +- Multi-task dispatch is fast and non-blocking. +- Trajectory tasks execute concurrently in the coordinator tick loop. +- No new hardware-safety behavior, rollback, or atomic all-or-nothing dispatch is introduced. + +Simulation and replay should mirror hardware behavior because group resolution and generated plan projection happen above hardware drivers. Existing single-arm simulation/replay stacks should continue through fallback if they form one unambiguous serial chain. + +## 18. Risks / Trade-offs + +- **API breakage:** Existing callers plan by robot name or robot ID. Mitigation: provide migration docs and temporary wrapper conveniences where appropriate. +- **Partial SRDF support confusion:** Users may expect full MoveIt SRDF semantics. Mitigation: warn on skipped groups and document supported forms clearly. +- **Fallback misclassification:** Terminal prismatic stripping may accidentally exclude a real controllable prismatic axis. Mitigation: fallback only applies to unambiguous single chains; SRDF is required for precise modeling. +- **Joint naming migration cost:** Resolved names require updates across planner results, state monitors, and execution projection. Mitigation: deterministic helper conversion and strict layering. +- **Backend capability mismatch:** Some planners may not support multi-robot coordinated planning. Mitigation: allow `UNSUPPORTED` while preserving interface semantics. +- **Dispatch skew for multiple trajectory tasks:** Sequential task invocation can create tiny start-time differences. Mitigation: accepted for now; future coordinator batch dispatch can address this if needed. + +## 19. Open Questions + +- Exact error/status enum names for unsupported groups, no target frame, overlapping groups, and backend-unsupported problems. +- Exact temporary compatibility wrappers, if any, for existing manipulation skill APIs. +- Whether to write a short ADR for removing planning config placement fields in favor of URDF/model placement. +- Whether future coordinator-level `execute_batch(...)` is needed for tighter multi-task synchronization. + +## Appendix: Design Q&A Summary + +- **Term:** Use Planning Group; avoid Move Group/movegroup. +- **Ownership:** Define groups at model/config level; resolve to runtime/world-bound groups. +- **Composites:** Do not create composite groups now; select multiple groups per request. +- **Multi-group meaning:** One coordinated joint-space problem and one synchronized result. +- **End effectors:** Groups are defined by chain/joints, not SRDF `` metadata. +- **Group declaration:** Support chain or ordered joint list; validate as serial chain. +- **Default selection:** Planning group selection is required; no implicit default selection. +- **IDs:** Planning Group ID is always `{robot_name}/{group_name}`. +- **Descriptors:** Query APIs return immutable snapshots, not live handles. +- **Selectors:** APIs may accept group ID strings or descriptors; no dedicated selector type. +- **Joint goals:** Joint target APIs are keyed by planning group at API boundary, then lowered to one ordered resolved-joint problem. +- **Resolution:** `WorldSpec` owns group listing/resolution; planner delegates resolution to world. +- **Joint state exactness:** Joint-space start/goal keys must exactly equal selected resolved joints. +- **Source of truth:** SRDF first, conservative fallback only when no SRDF is present. +- **Fallback name:** Generated group is `manipulator`. +- **Fallback source:** Use `RobotModelConfig.joint_names` as candidate controllable joints. +- **Prismatic joints:** Middle prismatic joints are allowed; terminal/tip prismatic finger joints may be excluded. +- **SRDF scope:** Parse `` only; ignore ``. +- **Unsupported SRDF:** Skip unsupported group forms with warnings. +- **SRDF discovery:** Explicit path, then warning auto-discovery, then fallback, then error. +- **Config fields:** Add `srdf_path`; remove planning-level `base_link`, `end_effector_link`, and `base_pose` in the new design. +- **Robot placement:** Placement belongs in URDF/model, not separate planning config transforms. +- **FK/Jacobian:** Replace robot-scoped end-effector APIs with group-scoped APIs. +- **Cross-robot planning:** Interface allows it; backends may report unsupported. +- **Overlaps:** Selected groups must never overlap in resolved joints. +- **Result shape:** `PlanningResult.path` remains combined and synchronized. +- **Stored plan:** Store selected group IDs and path only; project lazily for preview/execution. +- **Resolved naming:** Above parsing, use `{robot_name}/{local_joint_name}` everywhere. +- **Coordinator mapping:** Coordinator names are a control-boundary concern; default identity with resolved names. +- **Auxiliary groups:** Auxiliary means selected without pose constraint in this request only. +- **Auxiliary DOFs:** Auxiliary groups are free DOFs for pose planning. +- **Mixed targets:** No mixed pose+joint target API in this change. +- **IK:** IK solves over pose-targeted groups plus auxiliary groups. +- **IK result:** `IKResult.solution` contains exactly selected resolved joints. +- **Planning result:** `PlanningResult.path` contains exactly selected resolved joints. +- **Execution:** Split path by trajectory task and send to existing JTCs; no new batch/rollback semantics. +- **Generated plan:** Returned plan object is canonical; module last-plan storage is convenience. diff --git a/openspec/changes/add-planning-groups/proposal.md b/openspec/changes/add-planning-groups/proposal.md new file mode 100644 index 0000000000..ce14a0ee70 --- /dev/null +++ b/openspec/changes/add-planning-groups/proposal.md @@ -0,0 +1,53 @@ +## Why + +DimOS manipulation planning is currently robot-centric: planner and kinematics interfaces select a `robot_id`, while `RobotModelConfig` carries a single `joint_names`, `base_link`, and `end_effector_link` shape. That works for a single serial arm, but it hides the actual planning unit and makes torso+arm, dual-arm, and coordinated multi-group planning awkward or ambiguous. + +Planning groups should be first-class. Robot identity should describe the hardware/model instance, while planning group selection should describe which kinematic chains participate in IK and motion planning. This change also makes joint naming unambiguous above the model parsing layer by using stable resolved joint names. + +## What Changes + +- Add first-class planning group definitions sourced primarily from SRDF `` entries. +- Add conservative fallback generation of one `manipulator` planning group for unambiguous single-chain robots without SRDF. +- **BREAKING**: Planning and IK APIs move from robot-ID selection to explicit planning group selection. +- **BREAKING**: Joint states and generated paths above model parsing use resolved joint names of the form `{robot_name}/{local_joint_name}`. +- Add pose planning over pose-targeted planning groups plus request-scoped auxiliary planning groups that contribute free DOFs. +- Add coordinated multi-group planning semantics: one selected joint set, one synchronized path, and no overlapping selected joints. +- Replace robot-scoped end-effector FK/Jacobian queries with group-scoped queries. +- Introduce a minimal generated plan artifact that stores selected group IDs and a combined resolved-joint path, projecting lazily for preview and execution. +- Remove planning configuration concepts that duplicate robot model structure, including robot-level planning `base_link`, `end_effector_link`, and `base_pose` fields in the new design. + +## Affected DimOS Surfaces + +- Modules/streams: + - Manipulation planning module plan/preview/execute flow. + - Planning `WorldSpec`, `KinematicsSpec`, and `PlannerSpec` interfaces. + - Drake planning world implementation and world monitor/preview integration. + - Robot model/config parsing and model-to-planning config conversion. +- Blueprints/CLI: + - Existing manipulation blueprints should continue for single serial arms through fallback group generation. + - Ambiguous, branching, or multi-arm robots require SRDF rather than silent compatibility behavior. +- Skills/MCP: + - Manipulation skills that call pose or joint planning must select planning groups explicitly or use wrapper defaults supplied by the skill layer. +- Hardware/simulation/replay: + - Execution still sends projected trajectories to existing trajectory controller tasks. + - Multi-task execution dispatches per-task trajectories; trajectory controllers own runtime concurrency. + - No new hardware-safety behavior or atomic multi-task batch dispatch is introduced. +- Docs/generated registries: + - User/developer docs for manipulation planning APIs, SRDF support, fallback generation, and joint naming need updates. + - No generated blueprint registry changes are expected unless robot config/blueprint names change during implementation. + +## Capabilities + +### New Capabilities + +- `manipulation-planning-groups`: Planning group discovery, selection, coordinated planning, IK target semantics, generated plans, and preview/execution projection. + +### Modified Capabilities + +- None. No existing OpenSpec capability specs are present in this repository checkout. + +## Impact + +This is a public manipulation planning API redesign. Existing code that plans by robot name or assumes bare local joint names will need migration to explicit planning group IDs and resolved joint names. Existing single-arm robots without SRDF should keep working through generated `{robot_name}/manipulator` groups if their configured controllable joints form one unambiguous serial chain. + +The implementation needs tests for SRDF parsing, fallback generation, group resolution, resolved joint naming, IK with auxiliary groups, exact joint-target validation, multi-group planning result shape, and lazy preview/execution projection. Documentation should emphasize the distinction between robot identity, planning group identity, local model joint names, resolved joint names, and coordinator joint names. From 1730a5e7473d46a28dce9162984cb6100b3a618d Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 17 Jun 2026 15:59:41 -0700 Subject: [PATCH 034/110] feat: add manipulation planning groups --- .../test_manipulation_planning_groups.py | 219 +++++++ dimos/manipulation/manipulation_module.py | 565 +++++++++++++++++- .../planning/kinematics/jacobian_ik.py | 104 +++- .../kinematics/test_jacobian_ik_selection.py | 114 ++++ .../planning/monitor/world_monitor.py | 33 +- dimos/manipulation/planning/names.py | 88 +++ .../planning/planners/rrt_planner.py | 192 +++++- .../planners/test_rrt_planner_selection.py | 184 ++++++ .../manipulation/planning/planning_groups.py | 365 +++++++++++ dimos/manipulation/planning/spec/config.py | 21 +- dimos/manipulation/planning/spec/enums.py | 1 + dimos/manipulation/planning/spec/models.py | 93 ++- dimos/manipulation/planning/spec/protocols.py | 57 +- .../planning/test_planning_groups.py | 224 +++++++ .../planning/world/drake_world.py | 277 ++++++++- .../world/test_drake_world_planning_groups.py | 207 +++++++ dimos/manipulation/test_manipulation_unit.py | 187 ++++++ dimos/robot/config.py | 29 +- .../manipulation/adding_a_custom_arm.md | 25 +- .../manipulation/planning_groups.md | 121 ++++ docs/capabilities/manipulation/readme.md | 8 + openspec/changes/add-planning-groups/docs.md | 45 ++ .../manipulation-planning-groups/spec.md | 216 +++++++ openspec/changes/add-planning-groups/tasks.md | 92 +++ pyproject.toml | 1 + 25 files changed, 3385 insertions(+), 83 deletions(-) create mode 100644 dimos/e2e_tests/test_manipulation_planning_groups.py create mode 100644 dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py create mode 100644 dimos/manipulation/planning/names.py create mode 100644 dimos/manipulation/planning/planners/test_rrt_planner_selection.py create mode 100644 dimos/manipulation/planning/planning_groups.py create mode 100644 dimos/manipulation/planning/test_planning_groups.py create mode 100644 dimos/manipulation/planning/world/test_drake_world_planning_groups.py create mode 100644 docs/capabilities/manipulation/planning_groups.md create mode 100644 openspec/changes/add-planning-groups/docs.md create mode 100644 openspec/changes/add-planning-groups/specs/manipulation-planning-groups/spec.md create mode 100644 openspec/changes/add-planning-groups/tasks.md diff --git a/dimos/e2e_tests/test_manipulation_planning_groups.py b/dimos/e2e_tests/test_manipulation_planning_groups.py new file mode 100644 index 0000000000..70ae660b1a --- /dev/null +++ b/dimos/e2e_tests/test_manipulation_planning_groups.py @@ -0,0 +1,219 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Large E2E tests for manipulation planning groups with a coordinator. + +These tests launch a real ManipulationModule + ControlCoordinator blueprint and +exercise the public planning RPCs over LCM, matching the self-hosted large-test +style used by the navigation stack. +""" + +from __future__ import annotations + +from collections.abc import Callable +import importlib.util +import time +from typing import Any + +import pytest + +from dimos.control.coordinator import ControlCoordinator +from dimos.core.rpc_client import RPCClient +from dimos.e2e_tests.dimos_cli_call import DimosCliCall +from dimos.e2e_tests.lcm_spy import LcmSpy +from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.msgs.trajectory_msgs.TrajectoryStatus import TrajectoryState + +pytestmark = [pytest.mark.self_hosted_large] + +JOINT_STATE_TOPIC = "/coordinator/joint_state#sensor_msgs.JointState" +BLUEPRINT = "openarm-mock-planner-coordinator" + + +def _drake_available() -> bool: + return importlib.util.find_spec("pydrake") is not None + + +def _wait_for_robot_info( + client: RPCClient, + robot_name: str, + *, + timeout: float = 120.0, +) -> dict[str, Any]: + deadline = time.time() + timeout + last_error: BaseException | None = None + while time.time() < deadline: + try: + info = client.get_robot_info(robot_name) + if info and info.get("planning_groups"): + return info + except BaseException as exc: + last_error = exc + time.sleep(0.5) + raise TimeoutError(f"Timed out waiting for {robot_name!r} robot info") from last_error + + +def _wait_for_trajectory_completion( + client: RPCClient, + robot_name: str, + *, + timeout: float = 10.0, +) -> None: + deadline = time.time() + timeout + last_status: dict[str, Any] | None = None + while time.time() < deadline: + last_status = client.get_trajectory_status(robot_name) + if last_status is not None and last_status.get("state") == TrajectoryState.COMPLETED: + return + time.sleep(0.1) + raise TimeoutError(f"{robot_name!r} trajectory did not complete; last={last_status}") + + +def _wait_for_manipulation_state( + client: RPCClient, + state_name: str, + *, + timeout: float = 10.0, +) -> None: + deadline = time.time() + timeout + last_state: str | None = None + while time.time() < deadline: + last_state = client.get_state() + if last_state == state_name: + return + time.sleep(0.1) + raise TimeoutError(f"ManipulationModule did not reach {state_name}; last={last_state}") + + +def _wait_for_current_joints( + client: RPCClient, + robot_names: tuple[str, ...], + *, + timeout: float = 10.0, +) -> None: + deadline = time.time() + timeout + missing = robot_names + while time.time() < deadline: + missing = tuple( + robot_name + for robot_name in robot_names + if client.get_current_joints(robot_name) is None + ) + if not missing: + return + time.sleep(0.1) + raise TimeoutError(f"Timed out waiting for current joints from {missing}") + + +def _prepare_for_planning(client: RPCClient, robot_names: tuple[str, ...]) -> None: + client.reset() + _wait_for_manipulation_state(client, "IDLE") + _wait_for_current_joints(client, robot_names) + # Robot info and joint-state topics can become available just before the + # manipulation module finishes finalizing world monitors. Require a stable + # ready state after joint state is flowing to avoid command-readiness flakes. + time.sleep(0.25) + _wait_for_manipulation_state(client, "IDLE") + + +def _planning_group_id(info: dict[str, Any]) -> str: + groups = info["planning_groups"] + assert len(groups) == 1 + group_id = groups[0]["id"] + assert isinstance(group_id, str) + return group_id + + +def _offset_target(client: RPCClient, robot_name: str, delta: float) -> JointState: + current = client.get_current_joints(robot_name) + assert current is not None + return JointState(position=[position + delta for position in current]) + + +def _start_openarm_mock_planner( + start_blueprint: Callable[..., DimosCliCall], lcm_spy: LcmSpy +) -> None: + lcm_spy.save_topic(JOINT_STATE_TOPIC) + start_blueprint(BLUEPRINT) + lcm_spy.wait_for_saved_topic(JOINT_STATE_TOPIC, timeout=120.0) + + +@pytest.mark.skipif(not _drake_available(), reason="Drake not installed") +def test_single_arm_plans_and_executes_through_control_coordinator( + lcm_spy: LcmSpy, + start_blueprint: Callable[..., DimosCliCall], +) -> None: + """Plan with one arm and execute through its trajectory task.""" + _start_openarm_mock_planner(start_blueprint, lcm_spy) + + client = RPCClient(None, ManipulationModule) + coordinator_client = RPCClient(None, ControlCoordinator) + try: + left_info = _wait_for_robot_info(client, "left_arm") + left_id = _planning_group_id(left_info) + + tasks = coordinator_client.list_tasks() + assert left_info["coordinator_task_name"] in tasks + + _prepare_for_planning(client, ("left_arm",)) + + planned = client.plan_to_joint_targets({left_id: _offset_target(client, "left_arm", 0.02)}) + assert planned, client.get_error() + assert client.has_planned_path() + assert client.execute_plan(robot_name="left_arm") + + _wait_for_trajectory_completion(client, "left_arm") + finally: + coordinator_client.stop_rpc_client() + client.stop_rpc_client() + + +@pytest.mark.skipif(not _drake_available(), reason="Drake not installed") +def test_dual_arm_plans_and_dispatches_both_arms_through_control_coordinator( + lcm_spy: LcmSpy, + start_blueprint: Callable[..., DimosCliCall], +) -> None: + """Plan one generated plan over both arms and dispatch both JTC tasks.""" + _start_openarm_mock_planner(start_blueprint, lcm_spy) + + client = RPCClient(None, ManipulationModule) + coordinator_client = RPCClient(None, ControlCoordinator) + try: + left_info = _wait_for_robot_info(client, "left_arm") + right_info = _wait_for_robot_info(client, "right_arm") + left_id = _planning_group_id(left_info) + right_id = _planning_group_id(right_info) + + tasks = coordinator_client.list_tasks() + assert left_info["coordinator_task_name"] in tasks + assert right_info["coordinator_task_name"] in tasks + + _prepare_for_planning(client, ("left_arm", "right_arm")) + + planned = client.plan_to_joint_targets( + { + left_id: _offset_target(client, "left_arm", 0.02), + right_id: _offset_target(client, "right_arm", -0.02), + } + ) + assert planned, client.get_error() + assert client.has_planned_path() + assert client.execute_plan() + + _wait_for_trajectory_completion(client, "left_arm") + _wait_for_trajectory_completion(client, "right_arm") + finally: + coordinator_client.stop_rpc_client() + client.stop_rpc_client() diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index c5e2cafc84..70d6096a30 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -24,6 +24,7 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from enum import Enum import threading import time @@ -40,9 +41,19 @@ from dimos.core.stream import In from dimos.manipulation.planning.factory import create_kinematics, create_planner from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor +from dimos.manipulation.planning.names import to_resolved_joint_name from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import ObstacleType -from dimos.manipulation.planning.spec.models import JointPath, Obstacle, RobotName, WorldRobotID +from dimos.manipulation.planning.spec.models import ( + GeneratedPlan, + JointPath, + Obstacle, + PlanningGroupDescriptor, + PlanningGroupID, + PlanningResult, + RobotName, + WorldRobotID, +) from dimos.manipulation.planning.spec.protocols import KinematicsSpec, PlannerSpec from dimos.manipulation.planning.trajectory_generator.joint_trajectory_generator import ( JointTrajectoryGenerator, @@ -131,6 +142,7 @@ def __init__(self, **kwargs: Any) -> None: # Stored path for plan/preview/execute workflow (per robot) self._planned_paths: PlannedPaths = {} self._planned_trajectories: PlannedTrajectories = {} + self._last_plan: GeneratedPlan | None = None # Coordinator integration (lazy initialized) self._coordinator_client: RPCClient | None = None @@ -310,10 +322,20 @@ def _tf_publish_loop(self) -> None: break transforms: list[Transform] = [] for robot_id, config, _ in self._robots.values(): - # Publish world → EE - ee_pose = self._world_monitor.get_ee_pose(robot_id) - if ee_pose is not None: - ee_tf = Transform.from_pose(config.end_effector_link, ee_pose) + # Publish world → primary planning-group target frame. + # Fall back to deprecated robot-scoped EE only for legacy configs. + target_frame = config.end_effector_link + pose_group_id = self._primary_pose_group_id_for_robot(config.name) + if pose_group_id is not None: + pose_group = self._world_monitor.world.resolve_planning_groups( + (pose_group_id,) + )[0] + target_frame = pose_group.tip_link + ee_pose = self._world_monitor.get_group_pose(pose_group_id) + else: + ee_pose = self._world_monitor.get_ee_pose(robot_id) + if ee_pose is not None and target_frame is not None: + ee_tf = Transform.from_pose(target_frame, ee_pose) ee_tf.frame_id = "world" transforms.append(ee_tf) @@ -437,6 +459,247 @@ def _fail(self, msg: str) -> bool: self._error_message = msg return False + def _default_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: + """Return wrapper-level default group for legacy single-group RPCs.""" + assert self._world_monitor is not None + group_ids = [ + group.id + for group in self._world_monitor.world.list_planning_groups() + if group.robot_name == robot_name + ] + if len(group_ids) != 1: + logger.error( + "Robot '%s' has %d planning groups; select a planning group explicitly", + robot_name, + len(group_ids), + ) + return None + return group_ids[0] + + def _primary_pose_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: + """Return the first pose-targetable group for robot-scoped compatibility paths.""" + assert self._world_monitor is not None + for group in self._world_monitor.world.list_planning_groups(): + if group.robot_name == robot_name and group.has_pose_target: + return group.id + return None + + def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> JointState | None: + """Collect current state for exactly the selected resolved joints.""" + assert self._world_monitor is not None + resolved_groups = self._world_monitor.world.resolve_planning_groups(group_ids) + names: list[str] = [] + positions: list[float] = [] + current_by_robot: dict[WorldRobotID, dict[str, float]] = {} + + for group in resolved_groups: + if group.robot_id not in current_by_robot: + current = self._world_monitor.get_current_joint_state(group.robot_id) + if current is None: + logger.error("No joint state for robot '%s'", group.robot_name) + return None + current_by_robot[group.robot_id] = dict( + zip(current.name, current.position, strict=False) + ) + + robot_state = current_by_robot[group.robot_id] + for resolved_name, local_name in zip( + group.joint_names, group.local_joint_names, strict=True + ): + if resolved_name in robot_state: + position = robot_state[resolved_name] + elif local_name in robot_state: + position = robot_state[local_name] + else: + logger.error("Current state missing selected joint '%s'", resolved_name) + return None + names.append(resolved_name) + positions.append(position) + + return JointState(name=names, position=positions) + + def _normalize_group_selector( + self, selector: PlanningGroupID | PlanningGroupDescriptor + ) -> PlanningGroupID: + """Normalize a planning group selector to its stable public ID.""" + if isinstance(selector, PlanningGroupDescriptor): + return selector.id + return selector + + def _normalize_joint_target( + self, group_id: PlanningGroupID, target: JointState + ) -> JointState | None: + """Normalize a group joint target to resolved joint names in group order.""" + assert self._world_monitor is not None + group = self._world_monitor.world.resolve_planning_groups((group_id,))[0] + if not target.name: + if len(target.position) != len(group.joint_names): + logger.error("Target for '%s' has wrong joint count", group_id) + return None + return JointState(name=list(group.joint_names), position=list(target.position)) + + positions_by_name = dict(zip(target.name, target.position, strict=False)) + resolved_positions: list[float] = [] + for resolved_name, local_name in zip( + group.joint_names, group.local_joint_names, strict=False + ): + if resolved_name in positions_by_name: + resolved_positions.append(positions_by_name[resolved_name]) + elif local_name in positions_by_name: + resolved_positions.append(positions_by_name[local_name]) + else: + logger.error("Target for '%s' is missing joint '%s'", group_id, resolved_name) + return None + extra = set(target.name) - set(group.joint_names) - set(group.local_joint_names) + if extra: + logger.error("Target for '%s' has extra joints: %s", group_id, sorted(extra)) + return None + return JointState(name=list(group.joint_names), position=resolved_positions) + + def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotName) -> JointPath: + """Project combined plan path to one robot in configured local joint order. + + Generated plans only contain selected resolved joints. Trajectory tasks may + still be configured for the robot's full controllable joint set, so + non-selected joints are held at their current positions during projection. + """ + robot_id, config, _ = self._robots[robot_name] + resolved_joint_names = [ + to_resolved_joint_name(robot_name, joint) for joint in config.joint_names + ] + current_by_name: dict[str, float] = {} + if self._world_monitor is not None: + current = self._world_monitor.get_current_joint_state(robot_id) + if current is not None: + current_by_name = dict(zip(current.name, current.position, strict=False)) + projected: JointPath = [] + for waypoint in plan.path: + position_by_name = dict(zip(waypoint.name, waypoint.position, strict=False)) + positions: list[float] = [] + for local_name, resolved_name in zip( + config.joint_names, resolved_joint_names, strict=False + ): + if resolved_name in position_by_name: + positions.append(position_by_name[resolved_name]) + elif resolved_name in current_by_name: + positions.append(current_by_name[resolved_name]) + elif local_name in current_by_name: + positions.append(current_by_name[local_name]) + else: + logger.error( + "Cannot project plan for '%s': missing joint '%s'", + robot_name, + resolved_name, + ) + return [] + projected.append( + JointState( + name=list(config.joint_names), + position=positions, + ) + ) + return projected + + def _trajectory_for_robot_plan( + self, plan: GeneratedPlan, robot_name: RobotName + ) -> JointTrajectory | None: + """Generate a task-ordered trajectory for one affected robot lazily.""" + projected_path = self._project_plan_path_for_robot(plan, robot_name) + if len(projected_path) < 2: + logger.error("Plan projection for '%s' has fewer than two waypoints", robot_name) + return None + _, config, traj_gen = self._robots[robot_name] + trajectory = traj_gen.generate([list(state.position) for state in projected_path]) + return JointTrajectory( + joint_names=list(config.joint_names), + points=trajectory.points, + timestamp=trajectory.timestamp, + ) + + def _affected_robot_names(self, plan: GeneratedPlan) -> list[RobotName]: + """Get stable robot names affected by a generated plan.""" + assert self._world_monitor is not None + resolved_groups = self._world_monitor.world.resolve_planning_groups(plan.group_ids) + names: list[RobotName] = [] + for group in resolved_groups: + if group.robot_name not in names: + names.append(group.robot_name) + return names + + def _store_generated_plan( + self, group_ids: tuple[PlanningGroupID, ...], result: PlanningResult + ) -> None: + """Store canonical generated plan and compatibility per-robot projections.""" + self._last_plan = GeneratedPlan( + group_ids=group_ids, + path=result.path, + status=result.status, + planning_time=result.planning_time, + path_length=result.path_length, + iterations=result.iterations, + message=result.message, + ) + self._planned_paths.clear() + self._planned_trajectories.clear() + for robot_name in self._affected_robot_names(self._last_plan): + projected_path = self._project_plan_path_for_robot(self._last_plan, robot_name) + if projected_path: + self._planned_paths[robot_name] = projected_path + trajectory = self._trajectory_for_robot_plan(self._last_plan, robot_name) + if trajectory is not None: + self._planned_trajectories[robot_name] = trajectory + + def _plan_selected_path( + self, group_ids: tuple[PlanningGroupID, ...], start: JointState, goal: JointState + ) -> bool: + """Plan over an explicit planning group selection and store the result.""" + assert self._world_monitor and self._planner + result = self._planner.plan_selected_joint_path( + world=self._world_monitor.world, + group_ids=group_ids, + start=start, + goal=goal, + timeout=self.config.planning_timeout, + ) + if not result.is_success(): + return self._fail(f"Planning failed: {result.status.name}") + + logger.info("Path: %d waypoints", len(result.path)) + self._store_generated_plan(group_ids, result) + self._state = ManipulationState.COMPLETED + return True + + def _interpolate_preview_path( + self, + planned_path: JointPath, + trajectory: JointTrajectory | None, + animation_duration: float, + target_fps: float, + ) -> JointPath: + """Densify a planned path for visualization using a timed trajectory.""" + interpolated = list(planned_path) + if trajectory is None or target_fps <= 0 or animation_duration <= 0: + return interpolated + + times = np.array([point.time_from_start for point in trajectory.points], dtype=np.float64) + positions = np.array([point.positions for point in trajectory.points], dtype=np.float64) + if len(times) <= 1 or positions.ndim != 2 or times[-1] <= times[0]: + return interpolated + + frame_count = int(np.ceil(animation_duration * target_fps)) + 1 + sample_times = np.linspace(times[0], times[-1], frame_count) + joint_names = trajectory.joint_names or planned_path[0].name + sampled_positions = np.column_stack( + [ + np.interp(sample_times, times, positions[:, joint]) + for joint in range(positions.shape[1]) + ] + ) + return [ + JointState(name=joint_names, position=position.tolist()) + for position in sampled_positions + ] + def _dismiss_preview(self, robot_id: WorldRobotID) -> None: """Hide the preview ghost if the world supports it.""" if self._world_monitor is None: @@ -470,6 +733,23 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: orientation=pose.orientation, ) + group_id = self._default_group_id_for_robot(robot_name) + if group_id is not None: + ik = self._kinematics.solve_pose_targets( + world=self._world_monitor.world, + pose_targets={group_id: target_pose}, + seed=current, + check_collision=True, + ) + if not ik.is_success() or ik.joint_state is None: + return self._fail(f"IK failed: {ik.status.name}") + + start = self._selected_joint_state((group_id,)) + if start is None: + return self._fail("No joint state") + logger.info(f"IK solved, error: {ik.position_error:.4f}m") + return self._plan_selected_path((group_id,), start, ik.joint_state) + ik = self._kinematics.solve( world=self._world_monitor.world, robot_id=robot_id, @@ -483,6 +763,54 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: logger.info(f"IK solved, error: {ik.position_error:.4f}m") return self._plan_path_only(robot_name, robot_id, ik.joint_state) + @rpc + def plan_to_poses( + self, + pose_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, Pose], + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroupDescriptor] = (), + ) -> bool: + """Plan to one or more group pose targets with optional auxiliary groups.""" + if self._world_monitor is None or self._kinematics is None: + return False + if not pose_targets: + return self._fail("At least one pose target is required") + with self._lock: + if self._state not in (ManipulationState.IDLE, ManipulationState.COMPLETED): + logger.warning(f"Cannot plan: state is {self._state.name}") + return False + self._state = ManipulationState.PLANNING + + from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped + + stamped_targets = { + self._normalize_group_selector(group): PoseStamped( + frame_id="world", + position=pose.position, + orientation=pose.orientation, + ) + for group, pose in pose_targets.items() + } + auxiliary_ids = tuple(self._normalize_group_selector(group) for group in auxiliary_groups) + group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_ids))) + + try: + start = self._selected_joint_state(group_ids) + except Exception as exc: + return self._fail(f"Failed to resolve planning groups: {exc}") + if start is None: + return self._fail("No joint state") + + ik = self._kinematics.solve_pose_targets( + world=self._world_monitor.world, + pose_targets=stamped_targets, + auxiliary_groups=auxiliary_ids, + seed=start, + check_collision=True, + ) + if not ik.is_success() or ik.joint_state is None: + return self._fail(f"IK failed: {ik.status.name}") + return self._plan_selected_path(group_ids, start, ik.joint_state) + @rpc def plan_to_joints(self, joints: JointState, robot_name: RobotName | None = None) -> bool: """Plan motion to joint config. Use preview_path() then execute(). @@ -495,8 +823,53 @@ def plan_to_joints(self, joints: JointState, robot_name: RobotName | None = None return False robot_name, robot_id = r logger.info(f"Planning to joints for {robot_name}: {[f'{j:.3f}' for j in joints.position]}") + group_id = self._default_group_id_for_robot(robot_name) + if group_id is not None: + goal = self._normalize_joint_target(group_id, joints) + if goal is None: + return self._fail("Invalid joint target") + start = self._selected_joint_state((group_id,)) + if start is None: + return self._fail("No joint state") + return self._plan_selected_path((group_id,), start, goal) return self._plan_path_only(robot_name, robot_id, joints) + @rpc + def plan_to_joint_targets( + self, joint_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, JointState] + ) -> bool: + """Plan to joint targets keyed by planning group.""" + if self._world_monitor is None or self._planner is None: + return False + if not joint_targets: + return self._fail("At least one joint target is required") + with self._lock: + if self._state not in (ManipulationState.IDLE, ManipulationState.COMPLETED): + logger.warning(f"Cannot plan: state is {self._state.name}") + return False + self._state = ManipulationState.PLANNING + + group_ids = tuple(self._normalize_group_selector(group) for group in joint_targets) + try: + start = self._selected_joint_state(group_ids) + except Exception as exc: + return self._fail(f"Failed to resolve planning groups: {exc}") + if start is None: + return self._fail("No joint state") + + goal_names: list[str] = [] + goal_positions: list[float] = [] + for group, target in joint_targets.items(): + group_id = self._normalize_group_selector(group) + normalized = self._normalize_joint_target(group_id, target) + if normalized is None: + return self._fail(f"Invalid joint target for '{group_id}'") + goal_names.extend(normalized.name) + goal_positions.extend(normalized.position) + + goal = JointState(name=goal_names, position=goal_positions) + return self._plan_selected_path(group_ids, start, goal) + def _plan_path_only( self, robot_name: RobotName, robot_id: WorldRobotID, goal: JointState ) -> bool: @@ -537,6 +910,46 @@ def _plan_path_only( self._state = ManipulationState.COMPLETED return True + @rpc + def preview_plan( + self, + plan: GeneratedPlan | None = None, + duration: float | None = None, + robot_name: RobotName | None = None, + target_fps: float = 30.0, + ) -> bool: + """Preview a generated plan, defaulting to `_last_plan` when omitted.""" + if self._world_monitor is None: + return False + plan = plan or getattr(self, "_last_plan", None) + if plan is None or not plan.path: + logger.warning("No generated plan to preview") + return False + + robot_names = [robot_name] if robot_name is not None else self._affected_robot_names(plan) + previewed = False + for name in robot_names: + robot = self._get_robot(name) + if robot is None: + return False + resolved_name, robot_id, _, _ = robot + planned_path = self._project_plan_path_for_robot(plan, resolved_name) + if not planned_path: + logger.warning(f"No planned path to preview for {resolved_name}") + return False + trajectory = self._trajectory_for_robot_plan(plan, resolved_name) + animation_duration = ( + duration + if duration is not None + else (trajectory.duration if trajectory is not None else 3.0) + ) + interpolated = self._interpolate_preview_path( + planned_path, trajectory, animation_duration, target_fps + ) + self._world_monitor.animate_path(robot_id, interpolated, animation_duration) + previewed = True + return previewed + @rpc def preview_path( self, @@ -551,6 +964,10 @@ def preview_path( robot_name: Robot to preview (required if multiple robots configured) target_fps: Nominal preview update rate. Set <= 0 to use planned waypoints directly. """ + last_plan = getattr(self, "_last_plan", None) + if last_plan is not None and last_plan.path: + return self.preview_plan(last_plan, duration, robot_name, target_fps) + if self._world_monitor is None: return False @@ -571,26 +988,9 @@ def preview_path( trajectory = self._planned_trajectories.get(robot_name) animation_duration = duration - interpolated = list(planned_path) - if trajectory is not None and target_fps > 0 and animation_duration > 0: - times = np.array( - [point.time_from_start for point in trajectory.points], dtype=np.float64 - ) - positions = np.array([point.positions for point in trajectory.points], dtype=np.float64) - if len(times) > 1 and positions.ndim == 2 and times[-1] > times[0]: - frame_count = int(np.ceil(animation_duration * target_fps)) + 1 - sample_times = np.linspace(times[0], times[-1], frame_count) - joint_names = trajectory.joint_names or planned_path[0].name - sampled_positions = np.column_stack( - [ - np.interp(sample_times, times, positions[:, joint]) - for joint in range(positions.shape[1]) - ] - ) - interpolated = [ - JointState(name=joint_names, position=position.tolist()) - for position in sampled_positions - ] + interpolated = self._interpolate_preview_path( + planned_path, trajectory, animation_duration, target_fps + ) self._world_monitor.animate_path(robot_id, interpolated, animation_duration) return True @@ -601,11 +1001,14 @@ def has_planned_path(self) -> bool: Returns: True if a path is planned and ready """ + last_plan = getattr(self, "_last_plan", None) + if last_plan is not None: + return bool(last_plan.path) + robot = self._get_robot() if robot is None: return False robot_name, _, _, _ = robot - path = self._planned_paths.get(robot_name) return path is not None and len(path) > 0 @@ -627,13 +1030,9 @@ def clear_planned_path(self) -> bool: Returns: True if cleared """ - robot = self._get_robot() - if robot is None: - return False - robot_name, _, _, _ = robot - - self._planned_paths.pop(robot_name, None) - self._planned_trajectories.pop(robot_name, None) + self._last_plan = None + self._planned_paths.clear() + self._planned_trajectories.clear() return True @rpc @@ -660,13 +1059,33 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] return None robot_name, robot_id, config, _ = robot + planning_groups = ( + [ + { + "id": group.id, + "name": group.group_name, + "joint_names": list(group.joint_names), + "local_joint_names": list(group.local_joint_names), + "base_link": group.base_link, + "tip_link": group.tip_link, + "source": group.source, + "has_pose_target": group.has_pose_target, + } + for group in self._world_monitor.world.list_planning_groups() + if group.robot_name == robot_name + ] + if self._world_monitor is not None + else [] + ) return { "name": config.name, "world_robot_id": robot_id, "joint_names": config.joint_names, + "planning_groups": planning_groups, "end_effector_link": config.end_effector_link, "base_link": config.base_link, + "deprecated_fields": ["end_effector_link", "base_link"], "max_velocity": config.max_velocity, "max_acceleration": config.max_acceleration, "has_joint_name_mapping": bool(config.joint_name_mapping), @@ -778,6 +1197,10 @@ def _translate_trajectory_to_coordinator( @rpc def execute(self, robot_name: RobotName | None = None) -> bool: """Execute planned trajectory via ControlCoordinator.""" + last_plan = getattr(self, "_last_plan", None) + if last_plan is not None and last_plan.path: + return self.execute_plan(last_plan, robot_name) + if (robot := self._get_robot(robot_name)) is None: return False robot_name, _, config, _ = robot @@ -808,9 +1231,73 @@ def execute(self, robot_name: RobotName | None = None) -> bool: else: return self._fail("Coordinator rejected trajectory") + @rpc + def execute_plan( + self, plan: GeneratedPlan | None = None, robot_name: RobotName | None = None + ) -> bool: + """Project and execute a generated plan through affected trajectory tasks.""" + plan = plan or getattr(self, "_last_plan", None) + if plan is None or not plan.path: + logger.warning("No generated plan") + return False + if (client := self._get_coordinator_client()) is None: + logger.error("No coordinator client") + return False + + try: + affected = self._affected_robot_names(plan) + except Exception as exc: + return self._fail(f"Failed to resolve generated plan: {exc}") + robot_names = [robot_name] if robot_name is not None else affected + + dispatches: list[tuple[RobotName, RobotModelConfig, JointTrajectory]] = [] + for name in robot_names: + if name not in affected: + logger.error("Generated plan does not affect robot '%s'", name) + return False + robot = self._get_robot(name) + if robot is None: + return False + resolved_name, _, config, _ = robot + if not config.coordinator_task_name: + logger.error(f"No coordinator_task_name for '{resolved_name}'") + return False + trajectory = self._trajectory_for_robot_plan(plan, resolved_name) + if trajectory is None: + return False + dispatches.append((resolved_name, config, trajectory)) + + self._state = ManipulationState.EXECUTING + for name, config, trajectory in dispatches: + self._planned_trajectories[name] = trajectory + translated = self._translate_trajectory_to_coordinator(trajectory, config) + logger.info( + "Executing: task='%s', %d pts, %.2fs", + config.coordinator_task_name, + len(translated.points), + translated.duration, + ) + result = client.task_invoke( + config.coordinator_task_name, "execute", {"trajectory": translated} + ) + if not result: + return self._fail("Coordinator rejected trajectory") + + logger.info("Trajectory accepted") + self._state = ManipulationState.COMPLETED + return True + @rpc def get_trajectory_status(self, robot_name: RobotName | None = None) -> dict[str, Any] | None: """Get trajectory execution status via coordinator task_invoke.""" + last_plan = getattr(self, "_last_plan", None) + if robot_name is None and last_plan is not None and last_plan.path: + statuses = { + name: self.get_trajectory_status(name) + for name in self._affected_robot_names(last_plan) + } + return {"robots": statuses} + if (robot := self._get_robot(robot_name)) is None: return None _, _, config, _ = robot @@ -968,6 +1455,18 @@ def _wait_for_trajectory_completion( Returns: True if trajectory completed successfully """ + last_plan = getattr(self, "_last_plan", None) + if robot_name is None and last_plan is not None and last_plan.path: + try: + robot_names = self._affected_robot_names(last_plan) + except Exception as exc: + logger.warning("Failed to resolve generated plan while waiting: %s", exc) + return False + return all( + self._wait_for_trajectory_completion(name, timeout, poll_interval) + for name in robot_names + ) + robot = self._get_robot(robot_name) if robot is None: return True diff --git a/dimos/manipulation/planning/kinematics/jacobian_ik.py b/dimos/manipulation/planning/kinematics/jacobian_ik.py index 7727b6fa0f..7c810d6302 100644 --- a/dimos/manipulation/planning/kinematics/jacobian_ik.py +++ b/dimos/manipulation/planning/kinematics/jacobian_ik.py @@ -24,12 +24,18 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING import numpy as np from dimos.manipulation.planning.spec.enums import IKStatus -from dimos.manipulation.planning.spec.models import IKResult, WorldRobotID +from dimos.manipulation.planning.spec.models import ( + IKResult, + PlanningGroupDescriptor, + PlanningGroupID, + WorldRobotID, +) from dimos.manipulation.planning.spec.protocols import WorldSpec from dimos.manipulation.planning.utils.kinematics_utils import ( check_singularity, @@ -185,6 +191,85 @@ def solve( f"IK failed after {max_attempts} attempts", ) + def solve_pose_targets( + self, + world: WorldSpec, + pose_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, PoseStamped], + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroupDescriptor] = (), + seed: JointState | None = None, + position_tolerance: float = 0.001, + orientation_tolerance: float = 0.01, + check_collision: bool = True, + max_attempts: int = 10, + ) -> IKResult: + """Solve pose targets keyed by planning group with request-scoped auxiliaries. + + This backend currently supports one directly pose-targeted robot at a time. Auxiliary + groups are included in the selected result shape when they belong to the same resolved + planning problem; existing robot-scoped IK provides their seed/current values as free + degrees of freedom. + """ + if not pose_targets: + return _create_failure_result( + IKStatus.NO_SOLUTION, "At least one pose target is required" + ) + + pose_group_ids = tuple(_selector_id(group) for group in pose_targets.keys()) + auxiliary_group_ids = tuple(_selector_id(group) for group in auxiliary_groups) + selected_group_ids = pose_group_ids + auxiliary_group_ids + try: + resolved_groups = world.resolve_planning_groups(selected_group_ids) + except (KeyError, ValueError) as exc: + return _create_failure_result(IKStatus.NO_SOLUTION, str(exc)) + + if len(pose_group_ids) != 1: + return _create_failure_result( + IKStatus.NO_SOLUTION, + "JacobianIK supports exactly one pose target per request", + ) + + target_group = next(group for group in resolved_groups if group.id == pose_group_ids[0]) + if not target_group.has_pose_target: + return _create_failure_result( + IKStatus.NO_SOLUTION, + f"Planning group '{target_group.id}' has no pose target frame", + ) + + robot_ids = {group.robot_id for group in resolved_groups} + if len(robot_ids) != 1: + return _create_failure_result( + IKStatus.NO_SOLUTION, + "JacobianIK does not support cross-robot pose IK", + ) + + full_seed = seed + if full_seed is None: + with world.scratch_context() as ctx: + full_seed = world.get_joint_state(ctx, target_group.robot_id) + + target_pose = pose_targets[next(iter(pose_targets.keys()))] + result = self.solve( + world=world, + robot_id=target_group.robot_id, + target_pose=target_pose, + seed=full_seed, + position_tolerance=position_tolerance, + orientation_tolerance=orientation_tolerance, + check_collision=check_collision, + max_attempts=max_attempts, + ) + if not result.is_success() or result.joint_state is None: + return result + + selected_joint_names: list[str] = [] + for group in resolved_groups: + selected_joint_names.extend(group.joint_names) + try: + result.joint_state = _filter_joint_state(result.joint_state, selected_joint_names) + except ValueError as exc: + return _create_failure_result(IKStatus.NO_SOLUTION, str(exc)) + return result + def solve_iterative( self, world: WorldSpec, @@ -421,6 +506,23 @@ def _create_success_result( ) +def _selector_id(selector: PlanningGroupID | PlanningGroupDescriptor) -> PlanningGroupID: + if isinstance(selector, PlanningGroupDescriptor): + return selector.id + return selector + + +def _filter_joint_state(joint_state: JointState, joint_names: list[str]) -> JointState: + positions_by_name = dict(zip(joint_state.name, joint_state.position, strict=False)) + missing = [joint_name for joint_name in joint_names if joint_name not in positions_by_name] + if missing: + raise ValueError(f"IK result is missing selected joints: {missing}") + return JointState( + name=joint_names, + position=[positions_by_name[joint_name] for joint_name in joint_names], + ) + + def _create_failure_result( status: IKStatus, message: str, diff --git a/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py b/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py new file mode 100644 index 0000000000..a013af34ac --- /dev/null +++ b/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py @@ -0,0 +1,114 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for Jacobian IK selected planning group result contracts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import cast + +from dimos.manipulation.planning.kinematics.jacobian_ik import JacobianIK +from dimos.manipulation.planning.spec.enums import IKStatus +from dimos.manipulation.planning.spec.models import IKResult, ResolvedPlanningGroup +from dimos.manipulation.planning.spec.protocols import WorldSpec +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.JointState import JointState + + +def _pose() -> PoseStamped: + return PoseStamped(position=[0, 0, 0], orientation=[0, 0, 0, 1]) + + +def _joint_state(names: list[str], positions: list[float]) -> JointState: + return JointState({"name": names, "position": positions}) + + +def _group( + group_id: str, joint_names: tuple[str, ...], tip_link: str | None = "tool0" +) -> ResolvedPlanningGroup: + return ResolvedPlanningGroup( + id=group_id, + robot_id="robot_1", + robot_name="arm", + group_name=group_id.split("/", maxsplit=1)[1], + joint_names=joint_names, + local_joint_names=tuple(name.split("/", maxsplit=1)[1] for name in joint_names), + base_link="base_link", + tip_link=tip_link, + ) + + +class _IKWorld: + def __init__(self, groups: Mapping[str, ResolvedPlanningGroup]) -> None: + self._groups = groups + + def resolve_planning_groups( + self, group_ids: tuple[str, ...] + ) -> tuple[ResolvedPlanningGroup, ...]: + return tuple(self._groups[group_id] for group_id in group_ids) + + +class _SuccessfulIK(JacobianIK): + def solve( + self, + world: WorldSpec, + robot_id: str, + target_pose: PoseStamped, + seed: JointState | None = None, + position_tolerance: float = 0.001, + orientation_tolerance: float = 0.01, + check_collision: bool = True, + max_attempts: int = 10, + ) -> IKResult: + return IKResult( + status=IKStatus.SUCCESS, + joint_state=_joint_state( + ["arm/joint1", "arm/joint2", "arm/gripper", "arm/unrelated"], + [0.1, 0.2, 0.3, 0.4], + ), + ) + + +def test_solve_pose_targets_filters_result_to_target_and_auxiliary_joints() -> None: + world = _IKWorld( + { + "arm/arm": _group("arm/arm", ("arm/joint1", "arm/joint2")), + "arm/gripper": _group("arm/gripper", ("arm/gripper",), tip_link=None), + } + ) + + result = _SuccessfulIK().solve_pose_targets( + world=cast("WorldSpec", world), + pose_targets={"arm/arm": _pose()}, + auxiliary_groups=["arm/gripper"], + seed=_joint_state(["arm/joint1", "arm/joint2", "arm/gripper"], [0.0, 0.0, 0.0]), + ) + + assert result.status == IKStatus.SUCCESS + assert result.joint_state is not None + assert result.joint_state.name == ["arm/joint1", "arm/joint2", "arm/gripper"] + assert result.joint_state.position == [0.1, 0.2, 0.3] + + +def test_solve_pose_targets_rejects_group_without_pose_target_frame() -> None: + world = _IKWorld({"arm/gripper": _group("arm/gripper", ("arm/gripper",), tip_link=None)}) + + result = JacobianIK().solve_pose_targets( + world=cast("WorldSpec", world), + pose_targets={"arm/gripper": _pose()}, + ) + + assert result.status == IKStatus.NO_SOLUTION + assert "no pose target frame" in result.message diff --git a/dimos/manipulation/planning/monitor/world_monitor.py b/dimos/manipulation/planning/monitor/world_monitor.py index 0bcb032b3a..92e067b4b6 100644 --- a/dimos/manipulation/planning/monitor/world_monitor.py +++ b/dimos/manipulation/planning/monitor/world_monitor.py @@ -40,6 +40,7 @@ CollisionObjectMessage, JointPath, Obstacle, + PlanningGroupID, WorldRobotID, ) from dimos.manipulation.planning.spec.protocols import WorldSpec @@ -357,7 +358,10 @@ def get_min_distance(self, robot_id: WorldRobotID) -> float: def get_ee_pose( self, robot_id: WorldRobotID, joint_state: JointState | None = None ) -> PoseStamped: - """Get end-effector pose. Uses current state if joint_state is None.""" + """Get end-effector pose. Uses current state if joint_state is None. + + Deprecated: use get_group_pose() with an explicit planning group ID. + """ with self._world.scratch_context() as ctx: # If no state provided, fetch current from state monitor if joint_state is None: @@ -368,6 +372,19 @@ def get_ee_pose( return self._world.get_ee_pose(ctx, robot_id) + def get_group_pose( + self, group_id: PlanningGroupID, joint_state: JointState | None = None + ) -> PoseStamped: + """Get planning group target-frame pose using current state by default.""" + group = self._world.resolve_planning_groups((group_id,))[0] + with self._world.scratch_context() as ctx: + if joint_state is None: + joint_state = self.get_current_joint_state(group.robot_id) + if joint_state is not None: + self._world.set_joint_state(ctx, group.robot_id, joint_state) + + return self._world.get_group_pose(ctx, group_id) + def get_link_pose( self, robot_id: WorldRobotID, link_name: str, joint_state: JointState | None = None ) -> PoseStamped | None: @@ -401,11 +418,23 @@ def get_link_pose( ) def get_jacobian(self, robot_id: WorldRobotID, joint_state: JointState) -> NDArray[np.float64]: - """Get 6xN Jacobian matrix.""" + """Get robot-scoped 6xN Jacobian matrix. + + Deprecated: use get_group_jacobian() with an explicit planning group ID. + """ with self._world.scratch_context() as ctx: self._world.set_joint_state(ctx, robot_id, joint_state) return self._world.get_jacobian(ctx, robot_id) + def get_group_jacobian( + self, group_id: PlanningGroupID, joint_state: JointState + ) -> NDArray[np.float64]: + """Get planning group target-frame 6xN Jacobian matrix.""" + group = self._world.resolve_planning_groups((group_id,))[0] + with self._world.scratch_context() as ctx: + self._world.set_joint_state(ctx, group.robot_id, joint_state) + return self._world.get_group_jacobian(ctx, group_id) + # Lifecycle def finalize(self) -> None: diff --git a/dimos/manipulation/planning/names.py b/dimos/manipulation/planning/names.py new file mode 100644 index 0000000000..1b105eec56 --- /dev/null +++ b/dimos/manipulation/planning/names.py @@ -0,0 +1,88 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Name helpers for planning/model/coordinator boundary layers.""" + +from __future__ import annotations + +from dimos.manipulation.planning.spec.models import ( + LocalModelJointName, + PlanningGroupID, + ResolvedJointName, + RobotName, +) + + +def to_planning_group_id(robot_name: RobotName, group_name: str) -> PlanningGroupID: + """Build a public planning group ID.""" + if not robot_name or "/" in robot_name: + raise ValueError(f"Invalid robot name for planning group ID: {robot_name!r}") + if not group_name or "/" in group_name: + raise ValueError(f"Invalid planning group name: {group_name!r}") + return f"{robot_name}/{group_name}" + + +def split_planning_group_id(group_id: PlanningGroupID) -> tuple[RobotName, str]: + """Split and validate a planning group ID.""" + parts = group_id.split("/", maxsplit=1) + if len(parts) != 2 or not parts[0] or not parts[1] or "/" in parts[1]: + raise ValueError( + f"Invalid planning group ID {group_id!r}; expected '{{robot_name}}/{{group_name}}'" + ) + return parts[0], parts[1] + + +def to_resolved_joint_name( + robot_name: RobotName, + local_joint_name: LocalModelJointName, +) -> ResolvedJointName: + """Convert a local model joint name to a public resolved joint name.""" + if not robot_name or "/" in robot_name: + raise ValueError(f"Invalid robot name for resolved joint name: {robot_name!r}") + if not local_joint_name or "/" in local_joint_name: + raise ValueError(f"Invalid local joint name: {local_joint_name!r}") + return f"{robot_name}/{local_joint_name}" + + +def to_resolved_joint_names( + robot_name: RobotName, + local_joint_names: list[LocalModelJointName] | tuple[LocalModelJointName, ...], +) -> list[ResolvedJointName]: + """Convert local model joint names to public resolved joint names.""" + return [to_resolved_joint_name(robot_name, name) for name in local_joint_names] + + +def strip_resolved_joint_name( + robot_name: RobotName, + resolved_joint_name: ResolvedJointName, +) -> LocalModelJointName: + """Validate and strip a resolved joint name for backend internals.""" + prefix = f"{robot_name}/" + if not resolved_joint_name.startswith(prefix): + raise ValueError( + f"Resolved joint name {resolved_joint_name!r} does not belong to robot {robot_name!r}" + ) + local_name = resolved_joint_name[len(prefix) :] + if not local_name or "/" in local_name: + raise ValueError(f"Invalid resolved joint name: {resolved_joint_name!r}") + return local_name + + +__all__ = [ + "split_planning_group_id", + "strip_resolved_joint_name", + "to_planning_group_id", + "to_resolved_joint_name", + "to_resolved_joint_names", +] diff --git a/dimos/manipulation/planning/planners/rrt_planner.py b/dimos/manipulation/planning/planners/rrt_planner.py index 3ca19eb099..9e41a2e9ff 100644 --- a/dimos/manipulation/planning/planners/rrt_planner.py +++ b/dimos/manipulation/planning/planners/rrt_planner.py @@ -27,7 +27,13 @@ import numpy as np from dimos.manipulation.planning.spec.enums import PlanningStatus -from dimos.manipulation.planning.spec.models import JointPath, PlanningResult, WorldRobotID +from dimos.manipulation.planning.spec.models import ( + JointPath, + PlanningGroupID, + PlanningResult, + ResolvedPlanningGroup, + WorldRobotID, +) from dimos.manipulation.planning.spec.protocols import WorldSpec from dimos.manipulation.planning.utils.path_utils import compute_path_length from dimos.msgs.sensor_msgs.JointState import JointState @@ -98,6 +104,13 @@ def plan_joint_path( if error is not None: return error + if world.check_edge_collision_free(robot_id, start, goal, self._collision_step_size): + return _create_success_result( + [start, goal], + time.time() - start_time, + 0, + ) + lower, upper = world.get_joint_limits(robot_id) start_tree = [TreeNode(config=q_start.copy())] goal_tree = [TreeNode(config=q_goal.copy())] @@ -147,6 +160,126 @@ def get_name(self) -> str: """Get planner name.""" return "RRTConnect" + def plan_selected_joint_path( + self, + world: WorldSpec, + group_ids: list[PlanningGroupID] | tuple[PlanningGroupID, ...], + start: JointState, + goal: JointState, + timeout: float = 10.0, + ) -> PlanningResult: + """Plan a collision-free path for an explicit planning-group selection.""" + try: + resolved_groups = world.resolve_planning_groups(tuple(group_ids)) + except (KeyError, ValueError) as exc: + return _create_failure_result(PlanningStatus.INVALID_GOAL, str(exc)) + + selected_joint_names = [ + joint_name for group in resolved_groups for joint_name in group.joint_names + ] + exact_error = _validate_exact_joint_keys(start, selected_joint_names, "start") + if exact_error is not None: + return exact_error + exact_error = _validate_exact_joint_keys(goal, selected_joint_names, "goal") + if exact_error is not None: + return exact_error + + robot_ids = {group.robot_id for group in resolved_groups} + if len(robot_ids) != 1: + return self._plan_multi_robot_selected_joint_path( + world=world, + resolved_groups=resolved_groups, + start=start, + goal=goal, + timeout=timeout, + ) + + robot_id = next(iter(robot_ids)) + robot_config = world.get_robot_config(robot_id) + full_resolved_joint_names = [ + f"{robot_config.name}/{joint_name}" for joint_name in robot_config.joint_names + ] + if selected_joint_names != full_resolved_joint_names: + return _create_failure_result( + PlanningStatus.UNSUPPORTED, + "RRTConnectPlanner currently requires the selected groups to cover " + "the robot controllable joint set exactly", + ) + + return self.plan_joint_path( + world=world, + robot_id=robot_id, + start=_order_joint_state(start, selected_joint_names), + goal=_order_joint_state(goal, selected_joint_names), + timeout=timeout, + ) + + def _plan_multi_robot_selected_joint_path( + self, + world: WorldSpec, + resolved_groups: tuple[ResolvedPlanningGroup, ...], + start: JointState, + goal: JointState, + timeout: float, + ) -> PlanningResult: + """Plan each selected robot independently and synchronize waypoints. + + This supports coordinated joint targets across multiple robots when the + selected groups cover each affected robot's full controllable joint set. + Cross-robot collision coupling is not optimized by this backend; each + per-robot plan is still checked by the world backend for that robot. + """ + start_time = time.time() + groups_by_robot: dict[WorldRobotID, list[ResolvedPlanningGroup]] = {} + robot_order: list[WorldRobotID] = [] + for group in resolved_groups: + if group.robot_id not in groups_by_robot: + groups_by_robot[group.robot_id] = [] + robot_order.append(group.robot_id) + groups_by_robot[group.robot_id].append(group) + + paths_by_robot: dict[WorldRobotID, JointPath] = {} + total_iterations = 0 + for robot_id in robot_order: + robot_config = world.get_robot_config(robot_id) + robot_joint_names = [ + joint_name + for group in groups_by_robot[robot_id] + for joint_name in group.joint_names + ] + full_resolved_joint_names = [ + f"{robot_config.name}/{joint_name}" for joint_name in robot_config.joint_names + ] + if robot_joint_names != full_resolved_joint_names: + return _create_failure_result( + PlanningStatus.UNSUPPORTED, + "RRTConnectPlanner currently requires selected groups to cover " + "each affected robot's controllable joint set exactly", + ) + + remaining_timeout = max(timeout - (time.time() - start_time), 0.001) + result = self.plan_joint_path( + world=world, + robot_id=robot_id, + start=_order_joint_state(start, robot_joint_names), + goal=_order_joint_state(goal, robot_joint_names), + timeout=remaining_timeout, + ) + total_iterations += result.iterations + if not result.is_success(): + return result + paths_by_robot[robot_id] = result.path + + combined_path = _synchronize_robot_paths(robot_order, paths_by_robot) + return PlanningResult( + status=PlanningStatus.SUCCESS, + path=combined_path, + planning_time=time.time() - start_time, + path_length=compute_path_length(combined_path), + iterations=total_iterations, + message="Multi-robot plan composed from independently planned robot paths", + ) + def _validate_inputs( self, world: WorldSpec, @@ -344,3 +477,60 @@ def _create_failure_result( iterations=iterations, message=message, ) + + +def _synchronize_robot_paths( + robot_order: list[WorldRobotID], paths_by_robot: dict[WorldRobotID, JointPath] +) -> JointPath: + max_waypoints = max(len(path) for path in paths_by_robot.values()) + if max_waypoints == 0: + return [] + + combined: JointPath = [] + for waypoint_index in range(max_waypoints): + names: list[str] = [] + positions: list[float] = [] + for robot_id in robot_order: + path = paths_by_robot[robot_id] + if max_waypoints == 1 or len(path) == 1: + source_index = 0 + else: + source_index = round(waypoint_index * (len(path) - 1) / (max_waypoints - 1)) + waypoint = path[source_index] + names.extend(waypoint.name) + positions.extend(waypoint.position) + combined.append(JointState(name=names, position=positions)) + return combined + + +def _validate_exact_joint_keys( + joint_state: JointState, selected_joint_names: list[str], state_name: str +) -> PlanningResult | None: + actual_names = list(joint_state.name) + expected_names = selected_joint_names + if set(actual_names) != set(expected_names): + missing = [name for name in expected_names if name not in actual_names] + extra = [name for name in actual_names if name not in expected_names] + details: list[str] = [] + if missing: + details.append(f"missing={missing}") + if extra: + details.append(f"extra={extra}") + return _create_failure_result( + PlanningStatus.INVALID_START if state_name == "start" else PlanningStatus.INVALID_GOAL, + f"{state_name} joint names must exactly match selected joints ({', '.join(details)})", + ) + if len(joint_state.position) != len(joint_state.name): + return _create_failure_result( + PlanningStatus.INVALID_START if state_name == "start" else PlanningStatus.INVALID_GOAL, + f"{state_name} joint name and position lengths must match", + ) + return None + + +def _order_joint_state(joint_state: JointState, joint_names: list[str]) -> JointState: + position_by_name = dict(zip(joint_state.name, joint_state.position, strict=False)) + return JointState( + name=joint_names, + position=[position_by_name[name] for name in joint_names], + ) diff --git a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py new file mode 100644 index 0000000000..5d11e047d6 --- /dev/null +++ b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py @@ -0,0 +1,184 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for selected-joint RRT planning group contracts.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import numpy as np + +from dimos.manipulation.planning.planners.rrt_planner import RRTConnectPlanner +from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.manipulation.planning.spec.enums import PlanningStatus +from dimos.manipulation.planning.spec.models import ResolvedPlanningGroup +from dimos.manipulation.planning.spec.protocols import WorldSpec +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.JointState import JointState + + +def _pose() -> PoseStamped: + return PoseStamped(position=[0, 0, 0], orientation=[0, 0, 0, 1]) + + +def _robot_config(name: str, joint_names: list[str]) -> RobotModelConfig: + return RobotModelConfig( + name=name, + model_path=Path("robot.urdf"), + base_pose=_pose(), + joint_names=joint_names, + end_effector_link="tool0", + ) + + +def _joint_state(names: list[str], positions: list[float]) -> JointState: + return JointState({"name": names, "position": positions}) + + +def _group( + group_id: str, + robot_id: str, + robot_name: str, + joint_names: tuple[str, ...], +) -> ResolvedPlanningGroup: + return ResolvedPlanningGroup( + id=group_id, + robot_id=robot_id, + robot_name=robot_name, + group_name=group_id.split("/", maxsplit=1)[1], + joint_names=joint_names, + local_joint_names=tuple(name.split("/", maxsplit=1)[1] for name in joint_names), + base_link="base_link", + tip_link="tool0", + ) + + +class _SelectionWorld: + is_finalized = True + + def __init__( + self, + groups: dict[str, ResolvedPlanningGroup], + robot_configs: dict[str, RobotModelConfig], + ) -> None: + self._groups = groups + self._robot_configs = robot_configs + + def resolve_planning_groups( + self, group_ids: tuple[str, ...] + ) -> tuple[ResolvedPlanningGroup, ...]: + return tuple(self._groups[group_id] for group_id in group_ids) + + def get_robot_config(self, robot_id: str) -> RobotModelConfig: + return self._robot_configs[robot_id] + + def get_robot_ids(self) -> list[str]: + return list(self._robot_configs) + + def check_config_collision_free(self, robot_id: str, joint_state: JointState) -> bool: + return True + + def get_joint_limits(self, robot_id: str) -> tuple[np.ndarray, np.ndarray]: + joint_count = len(self._robot_configs[robot_id].joint_names) + return -np.ones(joint_count), np.ones(joint_count) + + def check_edge_collision_free( + self, + robot_id: str, + start: JointState, + goal: JointState, + step_size: float, + ) -> bool: + return True + + +def test_plan_selected_joint_path_rejects_missing_and_extra_start_names() -> None: + world = _SelectionWorld( + groups={"arm/arm": _group("arm/arm", "robot_1", "arm", ("arm/joint1", "arm/joint2"))}, + robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}, + ) + + result = RRTConnectPlanner().plan_selected_joint_path( + cast("WorldSpec", world), + ["arm/arm"], + start=_joint_state(["arm/joint1", "arm/extra"], [0.0, 0.0]), + goal=_joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]), + ) + + assert result.status == PlanningStatus.INVALID_START + assert "missing" in result.message + assert "extra" in result.message + + +def test_plan_selected_joint_path_rejects_missing_and_extra_goal_names() -> None: + world = _SelectionWorld( + groups={"arm/arm": _group("arm/arm", "robot_1", "arm", ("arm/joint1", "arm/joint2"))}, + robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}, + ) + + result = RRTConnectPlanner().plan_selected_joint_path( + cast("WorldSpec", world), + ["arm/arm"], + start=_joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]), + goal=_joint_state(["arm/joint1", "arm/extra"], [0.0, 0.0]), + ) + + assert result.status == PlanningStatus.INVALID_GOAL + assert "missing" in result.message + assert "extra" in result.message + + +def test_plan_selected_joint_path_plans_cross_robot_full_group_selection() -> None: + world = _SelectionWorld( + groups={ + "left/arm": _group("left/arm", "left_robot", "left", ("left/joint1",)), + "right/arm": _group("right/arm", "right_robot", "right", ("right/joint1",)), + }, + robot_configs={ + "left_robot": _robot_config("left", ["joint1"]), + "right_robot": _robot_config("right", ["joint1"]), + }, + ) + joint_state = _joint_state(["left/joint1", "right/joint1"], [0.0, 0.0]) + + result = RRTConnectPlanner().plan_selected_joint_path( + cast("WorldSpec", world), + ["left/arm", "right/arm"], + start=joint_state, + goal=_joint_state(["left/joint1", "right/joint1"], [0.1, -0.1]), + ) + + assert result.status == PlanningStatus.SUCCESS + assert len(result.path) == 2 + assert result.path[0].name == ["left/joint1", "right/joint1"] + assert result.path[-1].position == [0.1, -0.1] + + +def test_plan_selected_joint_path_rejects_single_robot_subset_selection() -> None: + world = _SelectionWorld( + groups={"arm/wrist": _group("arm/wrist", "robot_1", "arm", ("arm/joint2",))}, + robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}, + ) + joint_state = _joint_state(["arm/joint2"], [0.0]) + + result = RRTConnectPlanner().plan_selected_joint_path( + cast("WorldSpec", world), + ["arm/wrist"], + start=joint_state, + goal=joint_state, + ) + + assert result.status == PlanningStatus.UNSUPPORTED diff --git a/dimos/manipulation/planning/planning_groups.py b/dimos/manipulation/planning/planning_groups.py new file mode 100644 index 0000000000..ed082f42f0 --- /dev/null +++ b/dimos/manipulation/planning/planning_groups.py @@ -0,0 +1,365 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Planning group discovery from SRDF or conservative model fallback.""" + +from __future__ import annotations + +import itertools +from pathlib import Path +import warnings +import xml.etree.ElementTree as ET + +from dimos.manipulation.planning.spec.models import PlanningGroupDefinition +from dimos.robot.model_parser import JointDescription, ModelDescription +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +FALLBACK_PLANNING_GROUP_NAME = "manipulator" + + +class PlanningGroupDiscoveryError(ValueError): + """Raised when planning groups cannot be discovered for a model.""" + + +def _warn(message: str) -> None: + logger.warning(message) + warnings.warn(message, UserWarning, stacklevel=2) + + +def discover_planning_group_definitions( + *, + robot_name: str, + model_path: Path, + model: ModelDescription, + controllable_joint_names: list[str], + srdf_path: Path | None = None, +) -> list[PlanningGroupDefinition]: + """Discover planning groups from SRDF or fallback generation. + + Precedence is explicit SRDF path, conservative auto-discovery with warning, + then fallback generation from the controllable joint set. + """ + resolved_srdf_path = _resolve_srdf_path(model_path, srdf_path) + if resolved_srdf_path is not None: + groups = parse_srdf_planning_groups( + resolved_srdf_path, + model=model, + controllable_joint_names=controllable_joint_names, + ) + if groups: + return groups + _warn( + f"No supported planning groups found in SRDF {resolved_srdf_path} " + f"for robot {robot_name}; trying fallback generation" + ) + + return [ + generate_fallback_planning_group( + model=model, + controllable_joint_names=controllable_joint_names, + ) + ] + + +def parse_srdf_planning_groups( + srdf_path: Path, + *, + model: ModelDescription, + controllable_joint_names: list[str], +) -> list[PlanningGroupDefinition]: + """Parse supported SRDF planning group definitions. + + Supported forms are a single ```` + child or an ordered list of ```` children. Other forms, + including SRDF ```` metadata, are ignored for planning group + extraction. + """ + root = ET.parse(srdf_path).getroot() + groups: list[PlanningGroupDefinition] = [] + for group_elem in root.findall("group"): + group_name = group_elem.get("name") + if not group_name: + _warn(f"Skipping SRDF group without a name in {srdf_path}") + continue + + children = [child for child in list(group_elem) if isinstance(child.tag, str)] + chain_children = [child for child in children if child.tag == "chain"] + joint_children = [child for child in children if child.tag == "joint"] + unsupported_children = [child for child in children if child.tag not in {"chain", "joint"}] + + if len(chain_children) == 1 and not joint_children and not unsupported_children: + definition = _parse_chain_group( + group_name, + chain_children[0], + model=model, + controllable_joint_names=controllable_joint_names, + srdf_path=srdf_path, + ) + elif joint_children and len(joint_children) == len(children): + definition = _parse_joint_list_group( + group_name, + joint_children, + model=model, + controllable_joint_names=controllable_joint_names, + srdf_path=srdf_path, + ) + else: + child_tags = [child.tag for child in children] + _warn( + f"Skipping unsupported SRDF planning group {group_name} in " + f"{srdf_path} with child tags {child_tags}" + ) + definition = None + + if definition is not None: + groups.append(definition) + + return groups + + +def generate_fallback_planning_group( + *, + model: ModelDescription, + controllable_joint_names: list[str], +) -> PlanningGroupDefinition: + """Generate one conservative fallback planning group named ``manipulator``.""" + ordered_joints = _validate_and_order_serial_joints(model, controllable_joint_names) + while ordered_joints and ordered_joints[-1].type == "prismatic": + removed = ordered_joints.pop() + _warn( + f"Excluding terminal prismatic joint {removed.name} from " + f"fallback planning group {FALLBACK_PLANNING_GROUP_NAME}" + ) + + if not ordered_joints: + raise PlanningGroupDiscoveryError( + "Fallback planning group generation removed all candidate joints; provide SRDF" + ) + + return PlanningGroupDefinition( + name=FALLBACK_PLANNING_GROUP_NAME, + joint_names=tuple(joint.name for joint in ordered_joints), + base_link=ordered_joints[0].parent_link, + tip_link=ordered_joints[-1].child_link, + source="fallback", + ) + + +def _resolve_srdf_path(model_path: Path, srdf_path: Path | None) -> Path | None: + if srdf_path is not None: + if srdf_path.exists(): + return srdf_path + raise FileNotFoundError(f"SRDF file not found: {srdf_path}") + + for candidate in _srdf_auto_discovery_candidates(model_path): + if candidate.exists(): + _warn(f"Auto-discovered SRDF at {candidate}") + return candidate + return None + + +def _srdf_auto_discovery_candidates(model_path: Path) -> list[Path]: + candidates: list[Path] = [] + name = model_path.name + if name.endswith(".urdf.xacro"): + candidates.append(model_path.with_name(name.removesuffix(".urdf.xacro") + ".srdf")) + elif model_path.suffix: + candidates.append(model_path.with_suffix(".srdf")) + candidates.append(model_path.parent / "config" / "robot.srdf") + candidates.append(model_path.parent.parent / "config" / "robot.srdf") + return list(dict.fromkeys(candidates)) + + +def _parse_chain_group( + group_name: str, + chain_elem: ET.Element, + *, + model: ModelDescription, + controllable_joint_names: list[str], + srdf_path: Path, +) -> PlanningGroupDefinition | None: + base_link = chain_elem.get("base_link") + tip_link = chain_elem.get("tip_link") + if not base_link or not tip_link: + _warn( + f"Skipping SRDF chain group {group_name} in {srdf_path} because " + "base_link or tip_link is missing" + ) + return None + + try: + ordered_joints = _ordered_joints_between_links(model, base_link, tip_link) + controlled_joints = [j for j in ordered_joints if j.type != "fixed"] + _validate_controllable(group_name, controlled_joints, controllable_joint_names) + except PlanningGroupDiscoveryError as exc: + _warn(f"Skipping SRDF chain group {group_name} in {srdf_path}: {exc}") + return None + + return PlanningGroupDefinition( + name=group_name, + joint_names=tuple(joint.name for joint in controlled_joints), + base_link=base_link, + tip_link=tip_link, + source="srdf", + ) + + +def _parse_joint_list_group( + group_name: str, + joint_children: list[ET.Element], + *, + model: ModelDescription, + controllable_joint_names: list[str], + srdf_path: Path, +) -> PlanningGroupDefinition | None: + joint_names = [child.get("name", "") for child in joint_children] + if any(not name for name in joint_names): + _warn(f"Skipping SRDF joint-list group {group_name} in {srdf_path} with empty joint name") + return None + try: + ordered_joints = _validate_ordered_serial_joints(model, joint_names) + _validate_controllable(group_name, ordered_joints, controllable_joint_names) + except PlanningGroupDiscoveryError as exc: + _warn(f"Skipping SRDF joint-list group {group_name} in {srdf_path}: {exc}") + return None + + return PlanningGroupDefinition( + name=group_name, + joint_names=tuple(joint.name for joint in ordered_joints), + base_link=ordered_joints[0].parent_link, + tip_link=ordered_joints[-1].child_link, + source="srdf", + ) + + +def _ordered_joints_between_links( + model: ModelDescription, + base_link: str, + tip_link: str, +) -> list[JointDescription]: + joints_by_parent: dict[str, list[JointDescription]] = {} + for joint in model.joints: + joints_by_parent.setdefault(joint.parent_link, []).append(joint) + + ordered_joints: list[JointDescription] = [] + current_link = base_link + visited_links = {base_link} + while current_link != tip_link: + children = joints_by_parent.get(current_link, []) + if len(children) != 1: + raise PlanningGroupDiscoveryError( + f"chain from {base_link} to {tip_link} is branching or disconnected at {current_link}" + ) + joint = children[0] + ordered_joints.append(joint) + current_link = joint.child_link + if current_link in visited_links: + raise PlanningGroupDiscoveryError("chain contains a cycle") + visited_links.add(current_link) + + return ordered_joints + + +def _validate_ordered_serial_joints( + model: ModelDescription, + joint_names: list[str], +) -> list[JointDescription]: + ordered_joints: list[JointDescription] = [] + for joint_name in joint_names: + joint = model.get_joint(joint_name) + if joint is None: + raise PlanningGroupDiscoveryError(f"joint {joint_name} does not exist in model") + if joint.type == "fixed": + raise PlanningGroupDiscoveryError(f"joint {joint_name} is fixed") + ordered_joints.append(joint) + + if not ordered_joints: + raise PlanningGroupDiscoveryError("planning group contains no joints") + + for previous, current in itertools.pairwise(ordered_joints): + if previous.child_link != current.parent_link: + raise PlanningGroupDiscoveryError( + f"joints {previous.name} and {current.name} are not adjacent in a serial chain" + ) + return ordered_joints + + +def _validate_and_order_serial_joints( + model: ModelDescription, + joint_names: list[str], +) -> list[JointDescription]: + if not joint_names: + raise PlanningGroupDiscoveryError("fallback requires at least one controllable joint") + + joints: list[JointDescription] = [] + for joint_name in joint_names: + joint = model.get_joint(joint_name) + if joint is None: + raise PlanningGroupDiscoveryError(f"joint {joint_name} does not exist in model") + if joint.type == "fixed": + raise PlanningGroupDiscoveryError(f"joint {joint_name} is fixed") + joints.append(joint) + + by_parent = {joint.parent_link: joint for joint in joints} + by_child = {joint.child_link: joint for joint in joints} + if len(by_parent) != len(joints) or len(by_child) != len(joints): + raise PlanningGroupDiscoveryError("controllable joints branch or merge; provide SRDF") + + starts = [joint for joint in joints if joint.parent_link not in by_child] + ends = [joint for joint in joints if joint.child_link not in by_parent] + if len(starts) != 1 or len(ends) != 1: + raise PlanningGroupDiscoveryError( + "controllable joints are disconnected or cyclic; provide SRDF" + ) + + ordered_joints: list[JointDescription] = [] + current = starts[0] + while True: + ordered_joints.append(current) + next_joint = by_parent.get(current.child_link) + if next_joint is None: + break + current = next_joint + + if len(ordered_joints) != len(joints): + raise PlanningGroupDiscoveryError("controllable joints are disconnected; provide SRDF") + return ordered_joints + + +def _validate_controllable( + group_name: str, + joints: list[JointDescription], + controllable_joint_names: list[str], +) -> None: + if not joints: + raise PlanningGroupDiscoveryError( + f"planning group {group_name} contains no controllable joints" + ) + controllable = set(controllable_joint_names) + missing = [joint.name for joint in joints if joint.name not in controllable] + if missing: + raise PlanningGroupDiscoveryError( + f"planning group {group_name} includes joints outside controllable set: {missing}" + ) + + +__all__ = [ + "FALLBACK_PLANNING_GROUP_NAME", + "PlanningGroupDiscoveryError", + "discover_planning_group_definitions", + "generate_fallback_planning_group", + "parse_srdf_planning_groups", +] diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index 74dc3bd69b..b607fe6148 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -21,6 +21,7 @@ from pydantic import Field from dimos.core.module import ModuleConfig +from dimos.manipulation.planning.spec.models import PlanningGroupDefinition from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -30,10 +31,16 @@ class RobotModelConfig(ModuleConfig): Attributes: name: Human-readable robot name model_path: Path to robot model file (.urdf, .xacro, or .xml/MJCF) - base_pose: Pose of robot base in world frame (position + orientation) - joint_names: Ordered list of controlled joint names (in URDF namespace) - end_effector_link: Name of the end-effector link for FK/IK - base_link: Name of the base link (default: "base_link") + srdf_path: Optional path to SRDF file containing planning group definitions + base_pose: Deprecated planning placement transform retained for + compatibility. Prefer encoding placement in the robot model. + joint_names: Ordered list of controllable/coordinator joints in the + local model namespace. This is not a planning group. + end_effector_link: Deprecated robot-scoped end-effector link retained + for compatibility. Pose targets should use planning group target + frames instead. + base_link: Deprecated robot-scoped base link retained for Drake weld + compatibility. Planning groups own chain base links. package_paths: Dict mapping package names to filesystem Paths joint_limits_lower: Lower joint limits (radians) joint_limits_upper: Upper joint limits (radians) @@ -54,10 +61,12 @@ class RobotModelConfig(ModuleConfig): name: str model_path: Path - base_pose: PoseStamped + srdf_path: Path | None = None + base_pose: PoseStamped = Field(default_factory=PoseStamped) joint_names: list[str] - end_effector_link: str + end_effector_link: str | None = None base_link: str = "base_link" + planning_groups: list[PlanningGroupDefinition] = Field(default_factory=list) package_paths: dict[str, Path] = Field(default_factory=dict) joint_limits_lower: list[float] | None = None joint_limits_upper: list[float] | None = None diff --git a/dimos/manipulation/planning/spec/enums.py b/dimos/manipulation/planning/spec/enums.py index 66a17ee199..e1c7c1a735 100644 --- a/dimos/manipulation/planning/spec/enums.py +++ b/dimos/manipulation/planning/spec/enums.py @@ -47,3 +47,4 @@ class PlanningStatus(Enum): INVALID_GOAL = auto() COLLISION_AT_START = auto() COLLISION_AT_GOAL = auto() + UNSUPPORTED = auto() diff --git a/dimos/manipulation/planning/spec/models.py b/dimos/manipulation/planning/spec/models.py index 37daa331e4..729b5a7ac6 100644 --- a/dimos/manipulation/planning/spec/models.py +++ b/dimos/manipulation/planning/spec/models.py @@ -17,7 +17,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Literal, TypeAlias from dimos.manipulation.planning.spec.enums import ( IKStatus, @@ -39,6 +39,15 @@ WorldRobotID: TypeAlias = str """Internal Drake world robot ID""" +PlanningGroupID: TypeAlias = str +"""Public planning group ID of the form {robot_name}/{group_name}.""" + +LocalModelJointName: TypeAlias = str +"""Joint name as it appears in URDF/SRDF before world binding.""" + +ResolvedJointName: TypeAlias = str +"""Public joint name of the form {robot_name}/{local_joint_name}.""" + JointPath: TypeAlias = "list[JointState]" """List of joint states forming a path (each waypoint has names + positions)""" @@ -46,6 +55,88 @@ Jacobian: TypeAlias = "NDArray[np.float64]" """6 x n Jacobian matrix (rows: [vx, vy, vz, wx, wy, wz])""" +PlanningGroupSource: TypeAlias = Literal["srdf", "fallback"] + + +@dataclass(frozen=True) +class PlanningGroupDefinition: + """Model-level declaration of a planning group. + + Joint names are local model names. The definition is not bound to a world + robot ID and is safe to store on RobotModelConfig. + """ + + name: str + joint_names: tuple[LocalModelJointName, ...] + base_link: str + tip_link: str | None = None + source: PlanningGroupSource = "srdf" + + @property + def has_pose_target(self) -> bool: + """Whether this group has a valid pose target frame.""" + return self.tip_link is not None + + +@dataclass(frozen=True) +class PlanningGroupDescriptor: + """Read-only public snapshot for an available planning group.""" + + id: PlanningGroupID + robot_name: RobotName + group_name: str + joint_names: tuple[ResolvedJointName, ...] + local_joint_names: tuple[LocalModelJointName, ...] + base_link: str + tip_link: str | None = None + source: PlanningGroupSource = "srdf" + + @property + def has_pose_target(self) -> bool: + """Whether this group can be directly pose-targeted.""" + return self.tip_link is not None + + +@dataclass(frozen=True) +class ResolvedPlanningGroup: + """Runtime/world-bound planning group data.""" + + id: PlanningGroupID + robot_id: WorldRobotID + robot_name: RobotName + group_name: str + joint_names: tuple[ResolvedJointName, ...] + local_joint_names: tuple[LocalModelJointName, ...] + base_link: str + tip_link: str | None = None + source: PlanningGroupSource = "srdf" + + @property + def has_pose_target(self) -> bool: + """Whether this group can be directly pose-targeted.""" + return self.tip_link is not None + + +@dataclass +class GeneratedPlan: + """Canonical generated planning artifact. + + The path uses resolved joint names and contains exactly the selected joints. + Downstream preview/execution projections are computed lazily from this data. + """ + + group_ids: tuple[PlanningGroupID, ...] + path: list[JointState] = field(default_factory=list) + status: PlanningStatus = PlanningStatus.NO_SOLUTION + planning_time: float = 0.0 + path_length: float = 0.0 + iterations: int = 0 + message: str = "" + + def is_success(self) -> bool: + """Check if planning was successful.""" + return self.status == PlanningStatus.SUCCESS + @dataclass class Obstacle: diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index 1131312882..ed20ce57e1 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -33,7 +33,10 @@ IKResult, JointPath, Obstacle, + PlanningGroupDescriptor, + PlanningGroupID, PlanningResult, + ResolvedPlanningGroup, WorldRobotID, ) from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -71,6 +74,16 @@ def get_robot_config(self, robot_id: WorldRobotID) -> RobotModelConfig: """Get robot configuration.""" ... + def list_planning_groups(self) -> tuple[PlanningGroupDescriptor, ...]: + """List available planning groups as immutable descriptor snapshots.""" + ... + + def resolve_planning_groups( + self, group_ids: list[PlanningGroupID] | tuple[PlanningGroupID, ...] + ) -> tuple[ResolvedPlanningGroup, ...]: + """Resolve planning group IDs against current world robot data.""" + ... + def get_joint_limits( self, robot_id: WorldRobotID ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: # lower limits, upper limits @@ -155,8 +168,19 @@ def check_edge_collision_free( ... # Forward Kinematics (require context) + def get_group_pose(self, ctx: Any, group_id: PlanningGroupID) -> PoseStamped: + """Get pose for a planning group's target frame.""" + ... + + def get_group_jacobian(self, ctx: Any, group_id: PlanningGroupID) -> NDArray[np.float64]: + """Get planning group target-frame Jacobian over the group's selected joints.""" + ... + def get_ee_pose(self, ctx: Any, robot_id: WorldRobotID) -> PoseStamped: - """Get end-effector pose.""" + """Get robot-scoped end-effector pose. + + Deprecated: use get_group_pose() with an explicit planning group ID. + """ ... def get_link_pose( @@ -166,7 +190,10 @@ def get_link_pose( ... def get_jacobian(self, ctx: Any, robot_id: WorldRobotID) -> NDArray[np.float64]: - """Get end-effector Jacobian (6 x n_joints).""" + """Get robot-scoped end-effector Jacobian (6 x n_joints). + + Deprecated: use get_group_jacobian() with an explicit planning group ID. + """ ... @@ -224,6 +251,21 @@ def solve( """Solve IK with optional collision checking.""" ... + def solve_pose_targets( + self, + world: WorldSpec, + pose_targets: dict[PlanningGroupID | PlanningGroupDescriptor, PoseStamped], + auxiliary_groups: list[PlanningGroupID | PlanningGroupDescriptor] + | tuple[PlanningGroupID | PlanningGroupDescriptor, ...] = (), + seed: JointState | None = None, + position_tolerance: float = 0.001, + orientation_tolerance: float = 0.01, + check_collision: bool = True, + max_attempts: int = 10, + ) -> IKResult: + """Solve pose targets over planning groups plus request-scoped auxiliaries.""" + ... + @runtime_checkable class PlannerSpec(Protocol): @@ -249,6 +291,17 @@ def plan_joint_path( """Plan a collision-free joint-space path.""" ... + def plan_selected_joint_path( + self, + world: WorldSpec, + group_ids: list[PlanningGroupID] | tuple[PlanningGroupID, ...], + start: JointState, + goal: JointState, + timeout: float = 10.0, + ) -> PlanningResult: + """Plan over an explicit planning-group selection.""" + ... + def get_name(self) -> str: """Get planner name.""" ... diff --git a/dimos/manipulation/planning/test_planning_groups.py b/dimos/manipulation/planning/test_planning_groups.py new file mode 100644 index 0000000000..beb6984851 --- /dev/null +++ b/dimos/manipulation/planning/test_planning_groups.py @@ -0,0 +1,224 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for planning group discovery.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dimos.manipulation.planning.planning_groups import ( + FALLBACK_PLANNING_GROUP_NAME, + PlanningGroupDiscoveryError, + discover_planning_group_definitions, + generate_fallback_planning_group, + parse_srdf_planning_groups, +) +from dimos.robot.model_parser import JointDescription, ModelDescription + + +def _serial_model(*joint_types: str) -> ModelDescription: + joints = [ + JointDescription( + name=f"joint{i + 1}", + type=joint_type, + parent_link=f"link{i}", + child_link=f"link{i + 1}", + ) + for i, joint_type in enumerate(joint_types) + ] + return ModelDescription( + joints=joints, + root_link="link0", + links=[f"link{i}" for i in range(len(joint_types) + 1)], + ) + + +def _branching_model() -> ModelDescription: + return ModelDescription( + joints=[ + JointDescription( + name="left_joint", + type="revolute", + parent_link="base", + child_link="left_link", + ), + JointDescription( + name="right_joint", + type="revolute", + parent_link="base", + child_link="right_link", + ), + ], + root_link="base", + links=["base", "left_link", "right_link"], + ) + + +def _write_srdf(tmp_path: Path, body: str) -> Path: + srdf_path = tmp_path / "robot.srdf" + srdf_path.write_text(f"{body}") + return srdf_path + + +def test_parse_srdf_chain_group(tmp_path: Path) -> None: + model = _serial_model("revolute", "revolute", "revolute") + srdf_path = _write_srdf( + tmp_path, + "", + ) + + groups = parse_srdf_planning_groups( + srdf_path, + model=model, + controllable_joint_names=["joint1", "joint2", "joint3"], + ) + + assert len(groups) == 1 + assert groups[0].name == "arm" + assert groups[0].joint_names == ("joint1", "joint2", "joint3") + assert groups[0].base_link == "link0" + assert groups[0].tip_link == "link3" + assert groups[0].source == "srdf" + + +def test_parse_srdf_ordered_joint_list_group(tmp_path: Path) -> None: + model = _serial_model("revolute", "prismatic", "revolute") + srdf_path = _write_srdf( + tmp_path, + """ + + + + + + """, + ) + + groups = parse_srdf_planning_groups( + srdf_path, + model=model, + controllable_joint_names=["joint1", "joint2", "joint3"], + ) + + assert len(groups) == 1 + assert groups[0].joint_names == ("joint1", "joint2", "joint3") + assert groups[0].base_link == "link0" + assert groups[0].tip_link == "link3" + + +def test_parse_srdf_skips_unsupported_groups_and_ignores_end_effector( + tmp_path: Path, +) -> None: + model = _serial_model("revolute", "revolute") + srdf_path = _write_srdf( + tmp_path, + """ + + + + + """, + ) + + with pytest.warns(UserWarning) as warnings: + groups = parse_srdf_planning_groups( + srdf_path, + model=model, + controllable_joint_names=["joint1", "joint2"], + ) + + assert [group.name for group in groups] == ["arm"] + warning_text = "\n".join(str(warning.message) for warning in warnings) + assert "Skipping unsupported SRDF planning group links" in warning_text + assert "Skipping unsupported SRDF planning group nested" in warning_text + + +def test_fallback_generates_manipulator_for_unambiguous_serial_chain() -> None: + model = _serial_model("revolute", "prismatic", "revolute") + + group = generate_fallback_planning_group( + model=model, + controllable_joint_names=["joint2", "joint1", "joint3"], + ) + + assert group.name == FALLBACK_PLANNING_GROUP_NAME + assert group.joint_names == ("joint1", "joint2", "joint3") + assert group.base_link == "link0" + assert group.tip_link == "link3" + assert group.source == "fallback" + + +def test_fallback_strips_terminal_prismatic_joints() -> None: + model = _serial_model("revolute", "revolute", "prismatic") + + group = generate_fallback_planning_group( + model=model, + controllable_joint_names=["joint1", "joint2", "joint3"], + ) + + assert group.joint_names == ("joint1", "joint2") + assert group.tip_link == "link2" + + +def test_fallback_rejects_branching_model() -> None: + with pytest.raises(PlanningGroupDiscoveryError, match="branch"): + generate_fallback_planning_group( + model=_branching_model(), + controllable_joint_names=["left_joint", "right_joint"], + ) + + +def test_discovery_prefers_explicit_srdf_over_fallback(tmp_path: Path) -> None: + model = _serial_model("revolute", "revolute") + model_path = tmp_path / "robot.urdf" + model_path.write_text("") + srdf_path = _write_srdf( + tmp_path, + "", + ) + + groups = discover_planning_group_definitions( + robot_name="robot", + model_path=model_path, + model=model, + controllable_joint_names=["joint1", "joint2"], + srdf_path=srdf_path, + ) + + assert [group.name for group in groups] == ["srdf_arm"] + + +def test_discovery_auto_discovers_srdf_with_warning( + tmp_path: Path, +) -> None: + model = _serial_model("revolute") + model_path = tmp_path / "robot.urdf" + model_path.write_text("") + _write_srdf( + tmp_path, + "", + ) + + with pytest.warns(UserWarning, match="Auto-discovered SRDF"): + groups = discover_planning_group_definitions( + robot_name="robot", + model_path=model_path, + model=model, + controllable_joint_names=["joint1"], + ) + + assert [group.name for group in groups] == ["auto_arm"] diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 429e442434..7d6181f65e 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -25,9 +25,23 @@ import numpy as np +from dimos.manipulation.planning.names import ( + split_planning_group_id, + strip_resolved_joint_name, + to_planning_group_id, + to_resolved_joint_name, + to_resolved_joint_names, +) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import ObstacleType -from dimos.manipulation.planning.spec.models import JointPath, Obstacle, WorldRobotID +from dimos.manipulation.planning.spec.models import ( + JointPath, + Obstacle, + PlanningGroupDescriptor, + PlanningGroupID, + ResolvedPlanningGroup, + WorldRobotID, +) from dimos.manipulation.planning.spec.protocols import VisualizationSpec, WorldSpec from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake from dimos.utils.logging_config import setup_logger @@ -79,6 +93,22 @@ logger = setup_logger() +def _pose_stamped_from_drake_transform(transform: Any) -> PoseStamped: + """Convert a Drake RigidTransform-like object to a world-frame pose.""" + position = transform.translation() + quaternion = transform.rotation().ToQuaternion() + return PoseStamped( + frame_id="world", + position=[float(position[0]), float(position[1]), float(position[2])], + orientation=[ + float(quaternion.x()), + float(quaternion.y()), + float(quaternion.z()), + float(quaternion.w()), + ], + ) + + @dataclass class _RobotData: """Internal data for tracking a robot in the world.""" @@ -87,8 +117,8 @@ class _RobotData: config: RobotModelConfig model_instance: Any # ModelInstanceIndex joint_indices: list[int] # Indices into plant's position vector - ee_frame: Any # BodyFrame for end-effector - base_frame: Any # BodyFrame for base + ee_frame: Any | None # Deprecated robot-scoped end-effector frame + base_frame: Any # Deprecated robot-scoped base frame preview_model_instance: Any = None # ModelInstanceIndex for preview (yellow) robot preview_joint_indices: list[int] = field(default_factory=list) @@ -197,6 +227,8 @@ def add_robot(self, config: RobotModelConfig) -> WorldRobotID: """Add a robot to the world. Returns robot_id. Same model_path + base_pose reuses the model instance (e.g. two arms in one URDF). + base_pose/base_link/end_effector_link are deprecated compatibility fields; + planning should use planning-group base/tip links. """ if self._finalized: raise RuntimeError("Cannot add robot after world is finalized") @@ -210,9 +242,7 @@ def add_robot(self, config: RobotModelConfig) -> WorldRobotID: self._validate_joints(config, model_instance) - ee_frame = self._plant.GetBodyByName( - config.end_effector_link, model_instance - ).body_frame() + ee_frame = self._legacy_ee_frame(config, model_instance) base_frame = self._plant.GetBodyByName(config.base_link, model_instance).body_frame() # Preview (yellow ghost) — always a separate instance per robot @@ -234,6 +264,12 @@ def add_robot(self, config: RobotModelConfig) -> WorldRobotID: logger.info(f"Added robot '{robot_id}' ({config.name})") return robot_id + def _legacy_ee_frame(self, config: RobotModelConfig, model_instance: Any) -> Any | None: + """Resolve deprecated robot-scoped EE frame, if available.""" + if config.end_effector_link is None: + return None + return self._plant.GetBodyByName(config.end_effector_link, model_instance).body_frame() + def _load_model(self, config: RobotModelConfig) -> Any: """Load robot model (URDF/xacro/MJCF) and return model instance.""" original_path = config.model_path.resolve() @@ -312,6 +348,91 @@ def get_robot_config(self, robot_id: WorldRobotID) -> RobotModelConfig: raise KeyError(f"Robot '{robot_id}' not found") return self._robots[robot_id].config + def list_planning_groups(self) -> tuple[PlanningGroupDescriptor, ...]: + """List available planning groups as immutable descriptor snapshots.""" + descriptors: list[PlanningGroupDescriptor] = [] + for robot_data in self._robots.values(): + config = robot_data.config + for group in config.planning_groups: + descriptors.append( + PlanningGroupDescriptor( + id=to_planning_group_id(config.name, group.name), + robot_name=config.name, + group_name=group.name, + joint_names=tuple(to_resolved_joint_names(config.name, group.joint_names)), + local_joint_names=group.joint_names, + base_link=group.base_link, + tip_link=group.tip_link, + source=group.source, + ) + ) + return tuple(descriptors) + + def resolve_planning_groups( + self, group_ids: list[PlanningGroupID] | tuple[PlanningGroupID, ...] + ) -> tuple[ResolvedPlanningGroup, ...]: + """Resolve planning group IDs against current world robot data.""" + resolved_groups: list[ResolvedPlanningGroup] = [] + seen_joints: dict[str, PlanningGroupID] = {} + + for group_id in group_ids: + robot_name, group_name = split_planning_group_id(group_id) + robot_data = self._get_robot_data_by_name(robot_name) + group = next( + ( + candidate + for candidate in robot_data.config.planning_groups + if candidate.name == group_name + ), + None, + ) + if group is None: + raise KeyError(f"Unknown planning group ID: {group_id}") + + resolved_joint_names = tuple( + to_resolved_joint_name(robot_name, local_name) for local_name in group.joint_names + ) + for joint_name in resolved_joint_names: + previous_group_id = seen_joints.get(joint_name) + if previous_group_id is not None: + raise ValueError( + "Selected planning groups overlap on resolved joint " + f"{joint_name}: {previous_group_id} and {group_id}" + ) + seen_joints[joint_name] = group_id + + resolved_groups.append( + ResolvedPlanningGroup( + id=group_id, + robot_id=robot_data.robot_id, + robot_name=robot_name, + group_name=group_name, + joint_names=resolved_joint_names, + local_joint_names=group.joint_names, + base_link=group.base_link, + tip_link=group.tip_link, + source=group.source, + ) + ) + + return tuple(resolved_groups) + + def _get_robot_data_by_name(self, robot_name: str) -> _RobotData: + matches = [ + robot_data + for robot_data in self._robots.values() + if robot_data.config.name == robot_name + ] + if not matches: + raise KeyError(f"Robot '{robot_name}' not found for planning group resolution") + if len(matches) > 1: + raise ValueError(f"Robot name '{robot_name}' is not unique in planning world") + return matches[0] + + def _resolve_single_planning_group(self, group_id: PlanningGroupID) -> ResolvedPlanningGroup: + resolved_groups = self.resolve_planning_groups((group_id,)) + return resolved_groups[0] + def get_joint_limits( self, robot_id: WorldRobotID ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: @@ -763,8 +884,7 @@ def sync_from_joint_state(self, robot_id: WorldRobotID, joint_state: JointState) if not self._finalized or self._plant_context is None: return # Silently ignore before finalization - # Extract positions as numpy array for internal use - positions = np.array(joint_state.position, dtype=np.float64) + positions = self._positions_for_robot_state(robot_id, joint_state) with self._lock: self._set_positions_internal(self._plant_context, robot_id, positions) @@ -782,8 +902,7 @@ def set_joint_state( if not self._finalized: raise RuntimeError("World must be finalized first") - # Extract positions as numpy array for internal use - positions = np.array(joint_state.position, dtype=np.float64) + positions = self._positions_for_robot_state(robot_id, joint_state) # Get plant context from diagram context plant_ctx = self._diagram.GetMutableSubsystemContext(self._plant, ctx) @@ -804,6 +923,43 @@ def _set_positions_internal( self._plant.SetPositions(plant_ctx, full_positions) + def _positions_for_robot_state( + self, robot_id: WorldRobotID, joint_state: JointState + ) -> NDArray[np.float64]: + if robot_id not in self._robots: + raise KeyError(f"Robot '{robot_id}' not found") + robot_data = self._robots[robot_id] + local_joint_names = robot_data.config.joint_names + + if not joint_state.name: + if len(joint_state.position) != len(local_joint_names): + raise ValueError( + f"JointState position length {len(joint_state.position)} does not match " + f"robot {robot_data.config.name} joint count {len(local_joint_names)}" + ) + return np.array(joint_state.position, dtype=np.float64) + + state_by_local_name: dict[str, float] = {} + if len(joint_state.name) != len(joint_state.position): + raise ValueError("JointState name and position lengths must match") + + for name, position in zip(joint_state.name, joint_state.position, strict=False): + local_name = self._state_name_to_local(robot_data.config, name) + state_by_local_name[local_name] = float(position) + + missing = [name for name in local_joint_names if name not in state_by_local_name] + if missing: + raise ValueError( + f"JointState for robot {robot_data.config.name} is missing joints: {missing}" + ) + return np.array([state_by_local_name[name] for name in local_joint_names], dtype=np.float64) + + def _state_name_to_local(self, config: RobotModelConfig, joint_name: str) -> str: + try: + return strip_resolved_joint_name(config.name, joint_name) + except ValueError: + return config.get_urdf_joint_name(joint_name) + def get_joint_state(self, ctx: Context, robot_id: WorldRobotID) -> JointState: """Get robot joint state from given context.""" if not self._finalized: @@ -813,11 +969,19 @@ def get_joint_state(self, ctx: Context, robot_id: WorldRobotID) -> JointState: raise KeyError(f"Robot '{robot_id}' not found") robot_data = self._robots[robot_id] + if robot_data.ee_frame is None: + raise ValueError( + f"Robot '{robot_id}' has no robot-scoped end-effector link; " + "use get_group_pose() with an explicit planning group ID" + ) plant_ctx = self._diagram.GetSubsystemContext(self._plant, ctx) full_positions = self._plant.GetPositions(plant_ctx) positions = [float(full_positions[idx]) for idx in robot_data.joint_indices] - return JointState(name=robot_data.config.joint_names, position=positions) + return JointState( + name=to_resolved_joint_names(robot_data.config.name, robot_data.config.joint_names), + position=positions, + ) # Collision Checking (context-based) @@ -898,8 +1062,31 @@ def check_edge_collision_free( # Forward Kinematics (context-based) + def get_group_pose(self, ctx: Context, group_id: PlanningGroupID) -> PoseStamped: + """Get pose for a planning group's target frame.""" + if not self._finalized: + raise RuntimeError("World must be finalized first") + + group = self._resolve_single_planning_group(group_id) + if group.tip_link is None: + raise ValueError(f"Planning group '{group_id}' has no pose target frame") + + robot_data = self._robots[group.robot_id] + plant_ctx = self._diagram.GetSubsystemContext(self._plant, ctx) + + try: + tip_body = self._plant.GetBodyByName(group.tip_link, robot_data.model_instance) + except RuntimeError: + raise KeyError(f"Planning group '{group_id}' target link '{group.tip_link}' not found") + + tip_pose = self._plant.EvalBodyPoseInWorld(plant_ctx, tip_body) + return _pose_stamped_from_drake_transform(tip_pose) + def get_ee_pose(self, ctx: Context, robot_id: WorldRobotID) -> PoseStamped: - """Get end-effector pose.""" + """Get robot-scoped end-effector pose. + + Deprecated: use get_group_pose() with an explicit planning group ID. + """ if not self._finalized: raise RuntimeError("World must be finalized first") @@ -907,20 +1094,16 @@ def get_ee_pose(self, ctx: Context, robot_id: WorldRobotID) -> PoseStamped: raise KeyError(f"Robot '{robot_id}' not found") robot_data = self._robots[robot_id] + if robot_data.ee_frame is None: + raise ValueError( + f"Robot '{robot_id}' has no robot-scoped end-effector link; " + "use get_group_jacobian() with an explicit planning group ID" + ) plant_ctx = self._diagram.GetSubsystemContext(self._plant, ctx) ee_body = robot_data.ee_frame.body() X_WE = self._plant.EvalBodyPoseInWorld(plant_ctx, ee_body) - - # Extract position and quaternion from Drake transform - pos = X_WE.translation() - quat = X_WE.rotation().ToQuaternion() # Drake returns [w, x, y, z] - - return PoseStamped( - frame_id="world", - position=[float(pos[0]), float(pos[1]), float(pos[2])], - orientation=[float(quat.x()), float(quat.y()), float(quat.z()), float(quat.w())], - ) + return _pose_stamped_from_drake_transform(X_WE) def get_link_pose( self, ctx: Context, robot_id: WorldRobotID, link_name: str @@ -946,7 +1129,9 @@ def get_link_pose( return result # type: ignore[no-any-return] def get_jacobian(self, ctx: Context, robot_id: WorldRobotID) -> NDArray[np.float64]: - """Get geometric Jacobian (6 x n_joints). + """Get robot-scoped geometric Jacobian (6 x n_joints). + + Deprecated: use get_group_jacobian() with an explicit planning group ID. Rows: [vx, vy, vz, wx, wy, wz] (linear, then angular) """ @@ -981,6 +1166,52 @@ def get_jacobian(self, ctx: Context, robot_id: WorldRobotID) -> NDArray[np.float return J_reordered + def get_group_jacobian(self, ctx: Context, group_id: PlanningGroupID) -> NDArray[np.float64]: + """Get geometric Jacobian for a planning group's target frame. + + Rows: [vx, vy, vz, wx, wy, wz] (linear, then angular). Columns follow + the resolved planning group's joint order. + """ + if not self._finalized: + raise RuntimeError("World must be finalized first") + + group = self._resolve_single_planning_group(group_id) + if group.tip_link is None: + raise ValueError(f"Planning group '{group_id}' has no pose target frame") + + robot_data = self._robots[group.robot_id] + plant_ctx = self._diagram.GetSubsystemContext(self._plant, ctx) + + try: + tip_body = self._plant.GetBodyByName(group.tip_link, robot_data.model_instance) + except RuntimeError: + raise KeyError(f"Planning group '{group_id}' target link '{group.tip_link}' not found") + + jacobian_full = self._plant.CalcJacobianSpatialVelocity( + plant_ctx, + JacobianWrtVariable.kQDot, + tip_body.body_frame(), + np.array([0.0, 0.0, 0.0]), # type: ignore[arg-type] + self._plant.world_frame(), + self._plant.world_frame(), + ) + + joint_indices_by_name = dict( + zip(robot_data.config.joint_names, robot_data.joint_indices, strict=False) + ) + jacobian_group = np.zeros((6, len(group.local_joint_names))) + for index, local_joint_name in enumerate(group.local_joint_names): + try: + joint_index = joint_indices_by_name[local_joint_name] + except KeyError: + raise ValueError( + f"Planning group '{group_id}' references non-controllable joint " + f"'{local_joint_name}'" + ) + jacobian_group[:, index] = jacobian_full[:, joint_index] + + return np.vstack([jacobian_group[3:6, :], jacobian_group[0:3, :]]) + # Visualization def get_visualization_url(self) -> str | None: diff --git a/dimos/manipulation/planning/world/test_drake_world_planning_groups.py b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py new file mode 100644 index 0000000000..d4d4f4867e --- /dev/null +++ b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py @@ -0,0 +1,207 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for DrakeWorld planning group name/world resolution.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.manipulation.planning.spec.models import PlanningGroupDefinition +from dimos.manipulation.planning.world.drake_world import DrakeWorld, _RobotData +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.JointState import JointState + + +def _pose() -> PoseStamped: + return PoseStamped(position=[0, 0, 0], orientation=[0, 0, 0, 1]) + + +def _config( + name: str, + joint_names: list[str], + groups: list[PlanningGroupDefinition], + joint_name_mapping: dict[str, str] | None = None, +) -> RobotModelConfig: + return RobotModelConfig( + name=name, + model_path=Path("robot.urdf"), + base_pose=_pose(), + joint_names=joint_names, + end_effector_link="tool0", + base_link="base_link", + planning_groups=groups, + joint_name_mapping=joint_name_mapping or {}, + ) + + +def _world(*configs: RobotModelConfig) -> DrakeWorld: + world = DrakeWorld.__new__(DrakeWorld) + world._robots = { + f"robot_{index}": _RobotData( + robot_id=f"robot_{index}", + config=config, + model_instance=None, + joint_indices=[], + ee_frame=None, + base_frame=None, + ) + for index, config in enumerate(configs, start=1) + } + return world + + +def _arm_group(*joint_names: str) -> PlanningGroupDefinition: + return PlanningGroupDefinition( + name="arm", + joint_names=joint_names, + base_link="base_link", + tip_link="tool0", + source="srdf", + ) + + +def test_list_planning_groups_returns_stable_ids_and_resolved_joint_names() -> None: + world = _world(_config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")])) + + groups = world.list_planning_groups() + + assert len(groups) == 1 + assert groups[0].id == "left/arm" + assert groups[0].robot_name == "left" + assert groups[0].group_name == "arm" + assert groups[0].joint_names == ("left/joint1", "left/joint2") + assert groups[0].local_joint_names == ("joint1", "joint2") + + +def test_robot_model_config_allows_planning_groups_without_robot_scoped_ee() -> None: + config = RobotModelConfig( + name="left", + model_path=Path("robot.urdf"), + joint_names=["joint1"], + planning_groups=[_arm_group("joint1")], + ) + world = _world(config) + + groups = world.list_planning_groups() + + assert config.end_effector_link is None + assert groups[0].id == "left/arm" + + +def test_duplicate_local_joint_names_across_robots_are_disambiguated() -> None: + world = _world( + _config("left", ["joint1"], [_arm_group("joint1")]), + _config("right", ["joint1"], [_arm_group("joint1")]), + ) + + groups = world.list_planning_groups() + + assert [group.id for group in groups] == ["left/arm", "right/arm"] + assert [group.joint_names for group in groups] == [("left/joint1",), ("right/joint1",)] + + +def test_resolve_planning_groups_returns_robot_ids_and_joint_names() -> None: + world = _world( + _config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")]), + _config("right", ["joint1", "joint2"], [_arm_group("joint2")]), + ) + + resolved = world.resolve_planning_groups(("left/arm", "right/arm")) + + assert [group.id for group in resolved] == ["left/arm", "right/arm"] + assert [group.robot_id for group in resolved] == ["robot_1", "robot_2"] + assert [group.joint_names for group in resolved] == [ + ("left/joint1", "left/joint2"), + ("right/joint2",), + ] + assert [group.local_joint_names for group in resolved] == [("joint1", "joint2"), ("joint2",)] + + +def test_resolve_planning_groups_unknown_group_raises_key_error() -> None: + world = _world(_config("left", ["joint1"], [_arm_group("joint1")])) + + with pytest.raises(KeyError, match="Unknown planning group ID: left/gripper"): + world.resolve_planning_groups(("left/gripper",)) + + +def test_resolve_planning_groups_overlapping_same_robot_groups_raise_value_error() -> None: + world = _world( + _config( + "left", + ["joint1", "joint2"], + [ + _arm_group("joint1", "joint2"), + PlanningGroupDefinition( + name="wrist", + joint_names=("joint2",), + base_link="link1", + tip_link="tool0", + ), + ], + ) + ) + + with pytest.raises(ValueError, match="overlap.*left/joint2"): + world.resolve_planning_groups(("left/arm", "left/wrist")) + + +def test_positions_for_robot_state_accepts_resolved_joint_names_in_config_order() -> None: + world = _world(_config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")])) + joint_state = JointState({"name": ["left/joint2", "left/joint1"], "position": [2.0, 1.0]}) + + positions = world._positions_for_robot_state("robot_1", joint_state) + + np.testing.assert_allclose(positions, np.array([1.0, 2.0])) + + +def test_positions_for_robot_state_falls_back_to_coordinator_mapping() -> None: + world = _world( + _config( + "left", + ["urdf_joint1", "urdf_joint2"], + [_arm_group("urdf_joint1", "urdf_joint2")], + joint_name_mapping={"coord_joint1": "urdf_joint1", "coord_joint2": "urdf_joint2"}, + ) + ) + joint_state = JointState({"name": ["coord_joint2", "coord_joint1"], "position": [2.0, 1.0]}) + + positions = world._positions_for_robot_state("robot_1", joint_state) + + np.testing.assert_allclose(positions, np.array([1.0, 2.0])) + + +def test_group_pose_rejects_group_without_target_frame() -> None: + world = _world( + _config( + "left", + ["joint1"], + [ + PlanningGroupDefinition( + name="waist", + joint_names=("joint1",), + base_link="base_link", + tip_link=None, + ) + ], + ) + ) + world._finalized = True + + with pytest.raises(ValueError, match="left/waist.*no pose target frame"): + world.get_group_pose(None, "left/waist") diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 85afa6642c..d8cab7820f 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -28,6 +28,8 @@ ) from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.manipulation.planning.spec.enums import PlanningStatus +from dimos.manipulation.planning.spec.models import GeneratedPlan, ResolvedPlanningGroup from dimos.manipulation.planning.spec.protocols import VisualizationSpec from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -102,6 +104,7 @@ def _make_module(): module._planner = None module._kinematics = None module._coordinator_client = None + module._last_plan = None return module @@ -317,6 +320,65 @@ def _make_trajectory(*points: tuple[float, list[float]]) -> JointTrajectory: ) +def _make_robot_config( + name: str, + joints: list[str], + coordinator_joints: list[str], + task_name: str, +) -> RobotModelConfig: + return RobotModelConfig( + name=name, + model_path=Path("/path/to/robot.urdf"), + base_pose=PoseStamped(position=Vector3(), orientation=Quaternion()), + joint_names=joints, + end_effector_link="ee", + base_link="base", + joint_name_mapping=dict(zip(coordinator_joints, joints, strict=True)), + coordinator_task_name=task_name, + ) + + +def _make_resolved_group( + robot_name: str, group_name: str, joints: list[str] +) -> ResolvedPlanningGroup: + return ResolvedPlanningGroup( + id=f"{robot_name}/{group_name}", + robot_id=f"robot_{robot_name}", + robot_name=robot_name, + group_name=group_name, + joint_names=tuple(f"{robot_name}/{joint}" for joint in joints), + local_joint_names=tuple(joints), + base_link="base", + tip_link="ee", + ) + + +def _make_generated_plan(group_ids: tuple[str, ...], *points: list[float]) -> GeneratedPlan: + return GeneratedPlan( + group_ids=group_ids, + path=[ + JointState( + name=["left/j1", "left/j2", "right/j1"], + position=list(point), + ) + for point in points + ], + status=PlanningStatus.SUCCESS, + ) + + +def _trajectory_generator() -> MagicMock: + generator = MagicMock() + generator.generate.side_effect = lambda positions: JointTrajectory( + joint_names=[], + points=[ + TrajectoryPoint(time_from_start=float(index), positions=list(position)) + for index, position in enumerate(positions) + ], + ) + return generator + + def _make_world_monitor_with_viz(viz: object | None) -> WorldMonitor: world = viz if viz is not None else object() with patch( @@ -566,3 +628,128 @@ def test_preview_path_returns_false_for_missing_inputs(self): module._robots = {"arm": ("robot_id", MagicMock(), MagicMock())} module._planned_paths = {"arm": []} assert module.preview_path(robot_name="arm") is False + + +class TestGeneratedPlanProjection: + def test_selected_joint_state_accepts_local_current_state_names(self): + config = _make_robot_config("left", ["j1", "j2"], ["c/j1", "c/j2"], "task") + module = _make_module_with_monitor(config) + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_resolved_group("left", "arm", ["j1", "j2"]) + ] + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1", "j2"], position=[1.0, 2.0] + ) + + selected = module._selected_joint_state(("left/arm",)) + + assert selected is not None + assert selected.name == ["left/j1", "left/j2"] + assert selected.position == [1.0, 2.0] + + def test_execute_plan_dispatches_one_trajectory_per_affected_robot(self): + left_config = _make_robot_config( + "left", + ["j1", "j2", "j3"], + ["left_task/j1", "left_task/j2", "left_task/j3"], + "left_task", + ) + right_config = _make_robot_config( + "right", ["j1", "j2"], ["right_task/j1", "right_task/j2"], "right_task" + ) + module = _make_module_with_monitor(left_config, right_config) + left_gen = _trajectory_generator() + right_gen = _trajectory_generator() + module._robots["left"] = ("robot_left", left_config, left_gen) + module._robots["right"] = ("robot_right", right_config, right_gen) + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_resolved_group("left", "arm", ["j1", "j2"]), + _make_resolved_group("right", "arm", ["j1"]), + ] + module._world_monitor.get_current_joint_state.side_effect = [ + JointState(name=["left/j1", "left/j2", "left/j3"], position=[0.0, 0.0, 9.0]), + JointState(name=["right/j1", "right/j2"], position=[0.0, 8.0]), + ] + module._coordinator_client = MagicMock() + module._coordinator_client.task_invoke.return_value = True + plan = _make_generated_plan(("left/arm", "right/arm"), [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]) + + assert module.execute_plan(plan) is True + + assert module._coordinator_client.task_invoke.call_count == 2 + left_call, right_call = module._coordinator_client.task_invoke.call_args_list + assert left_call.args[0:2] == ("left_task", "execute") + left_trajectory = left_call.args[2]["trajectory"] + assert left_trajectory.joint_names == ["left_task/j1", "left_task/j2", "left_task/j3"] + assert [point.positions for point in left_trajectory.points] == [ + [1.0, 2.0, 9.0], + [4.0, 5.0, 9.0], + ] + assert right_call.args[0:2] == ("right_task", "execute") + right_trajectory = right_call.args[2]["trajectory"] + assert right_trajectory.joint_names == ["right_task/j1", "right_task/j2"] + assert [point.positions for point in right_trajectory.points] == [[3.0, 8.0], [6.0, 8.0]] + + def test_project_plan_holds_non_selected_joints_from_current_state(self): + config = _make_robot_config("left", ["j1", "j2", "j3"], ["c/j1", "c/j2", "c/j3"], "task") + module = _make_module_with_monitor(config) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["left/j1", "left/j2", "left/j3"], position=[10.0, 20.0, 30.0] + ) + plan = GeneratedPlan( + group_ids=("left/arm",), + path=[ + JointState(name=["left/j2"], position=[2.0]), + JointState(name=["left/j2"], position=[3.0]), + ], + status=PlanningStatus.SUCCESS, + ) + + projected = module._project_plan_path_for_robot(plan, "left") + + assert [state.name for state in projected] == [["j1", "j2", "j3"], ["j1", "j2", "j3"]] + assert [state.position for state in projected] == [[10.0, 2.0, 30.0], [10.0, 3.0, 30.0]] + + def test_preview_path_with_last_plan_projects_lazily_to_world_monitor(self): + config = _make_robot_config("left", ["j1", "j2"], ["c/j1", "c/j2"], "task") + module = _make_module_with_monitor(config) + module._robots["left"] = ("robot_left", config, _trajectory_generator()) + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_resolved_group("left", "arm", ["j1"]) + ] + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["left/j1", "left/j2"], position=[0.0, 7.0] + ) + module._last_plan = GeneratedPlan( + group_ids=("left/arm",), + path=[ + JointState(name=["left/j1"], position=[1.0]), + JointState(name=["left/j1"], position=[2.0]), + ], + status=PlanningStatus.SUCCESS, + ) + + assert module.preview_path(robot_name="left", target_fps=0.0) is True + + module._world_monitor.animate_path.assert_called_once() + robot_id, path, duration = module._world_monitor.animate_path.call_args.args + assert robot_id == "robot_left" + assert duration == 1.0 + assert [state.position for state in path] == [[1.0, 7.0], [2.0, 7.0]] + + def test_has_and_clear_planned_path_use_last_plan(self): + module = _make_module() + module._last_plan = GeneratedPlan( + group_ids=("left/arm",), + path=[JointState(name=["left/j1"], position=[1.0])], + status=PlanningStatus.SUCCESS, + ) + module._planned_paths = {"left": _make_path([1.0])} + module._planned_trajectories = {"left": _make_trajectory((0.0, [1.0]))} + + assert module.has_planned_path() is True + assert module.clear_planned_path() is True + assert module.has_planned_path() is False + assert module._last_plan is None + assert module._planned_paths == {} + assert module._planned_trajectories == {} diff --git a/dimos/robot/config.py b/dimos/robot/config.py index d0922cb842..e701099480 100644 --- a/dimos/robot/config.py +++ b/dimos/robot/config.py @@ -28,6 +28,7 @@ from dimos.control.components import HardwareComponent, HardwareType from dimos.control.coordinator import TaskConfig +from dimos.manipulation.planning.planning_groups import discover_planning_group_definitions from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -54,7 +55,7 @@ class RobotConfig(BaseModel): # Required fields name: str model_path: Path | None = None - end_effector_link: str | None = None + end_effector_link: str | None = None # Deprecated for planning; use planning group tip links. # Physical dimensions (meters) height_clearance: float | None = None # max height @@ -75,11 +76,14 @@ class RobotConfig(BaseModel): # Optional overrides (derived from model if not set) joint_names: list[str] | None = None - base_link: str | None = None + base_link: str | None = ( + None # Deprecated planning override; derived from model root when absent. + ) home_joints: list[float] | None = None # Multi-robot / coordinator joint_prefix: str | None = None # defaults to "{name}_" + # Deprecated for planning placement. Prefer encoding placement in URDF/xacro/MJCF. base_pose: list[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) # Planning @@ -94,6 +98,7 @@ class RobotConfig(BaseModel): package_paths: dict[str, Path] = Field(default_factory=dict) xacro_args: dict[str, str] = Field(default_factory=dict) auto_convert_meshes: bool = True + srdf_path: Path | None = None # TF publishing tf_extra_links: list[str] = Field(default_factory=list) @@ -193,11 +198,6 @@ def coordinator_task_name(self) -> str: def to_robot_model_config(self) -> RobotModelConfig: """Generate RobotModelConfig for ManipulationModule.""" - if self.end_effector_link is None: - raise ValueError( - f"RobotConfig '{self.name}' has no end_effector_link — " - "cannot generate RobotModelConfig for manipulation." - ) if self.model_path is None: raise ValueError( f"RobotConfig '{self.name}' has no model_path — " @@ -218,14 +218,27 @@ def to_robot_model_config(self) -> RobotModelConfig: self.joint_names if self.joint_names is not None else self.resolved_joint_names ) base_link = self.base_link if self.base_link is not None else self.resolved_base_link + planning_groups = discover_planning_group_definitions( + robot_name=self.name, + model_path=self.model_path, + model=self.model_description, + controllable_joint_names=joint_names, + srdf_path=self.srdf_path, + ) + legacy_end_effector_link = self.end_effector_link or next( + (group.tip_link for group in planning_groups if group.tip_link is not None), + None, + ) return RobotModelConfig( name=self.name, model_path=self.model_path, + srdf_path=self.srdf_path, base_pose=base_pose, joint_names=joint_names, - end_effector_link=self.end_effector_link, + end_effector_link=legacy_end_effector_link, base_link=base_link, + planning_groups=planning_groups, package_paths=self.package_paths, xacro_args=self.xacro_args, collision_exclusion_pairs=exclusions, diff --git a/docs/capabilities/manipulation/adding_a_custom_arm.md b/docs/capabilities/manipulation/adding_a_custom_arm.md index 1e0a14a2a0..3b373e4daf 100644 --- a/docs/capabilities/manipulation/adding_a_custom_arm.md +++ b/docs/capabilities/manipulation/adding_a_custom_arm.md @@ -509,6 +509,7 @@ Place your URDF/xacro files under LFS data so they can be resolved via `LfsPath` from dimos.utils.data import LfsPath from dimos.manipulation.manipulation_module import manipulation_module from dimos.manipulation.planning.spec import RobotModelConfig +from dimos.manipulation.planning.spec.models import PlanningGroupDefinition from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -550,10 +551,18 @@ def _make_yourarm_config( return RobotModelConfig( name=name, model_path=_YOURARM_URDF_PATH, - base_pose=_make_base_pose(y=y_offset), joint_names=joint_names, - end_effector_link="link6", # Last link in your URDF's kinematic chain - base_link="base_link", # Root link of your URDF + planning_groups=[ + PlanningGroupDefinition( + name="manipulator", + joint_names=tuple(joint_names), + base_link="base_link", + tip_link="link6", + source="fallback", + ) + ], + base_pose=_make_base_pose(y=y_offset), # Deprecated; prefer model placement + base_link="base_link", # Deprecated robot-scoped compatibility package_paths={"yourarm_description": _YOURARM_PACKAGE_PATH}, xacro_args={}, # Xacro arguments if using .xacro files collision_exclusion_pairs=[], # Pairs of links that can touch (e.g., gripper fingers) @@ -587,14 +596,18 @@ yourarm_planner = manipulation_module( | Field | Description | |-------|-------------| | `model_path` | Path to `.urdf` or `.xacro` file | -| `joint_names` | Ordered list of controlled joints (must match URDF) | -| `end_effector_link` | Link to use as the end-effector for IK | -| `base_link` | Root link of the robot model | +| `joint_names` | Ordered controllable/coordinator joint set (must match URDF); not itself a planning group | +| `planning_groups` / `srdf_path` | Explicit planning groups or SRDF source; fallback can generate `{robot_name}/manipulator` for an unambiguous single chain | | `package_paths` | Maps `package://` URIs to filesystem paths (for xacro) | | `joint_name_mapping` | Maps coordinator names (e.g., `"arm_joint1"`) to URDF names (e.g., `"joint1"`) | | `coordinator_task_name` | Must match the `TaskConfig.name` in your coordinator blueprint | | `collision_exclusion_pairs` | List of `(link_a, link_b)` tuples for links that may legitimately touch (e.g., gripper fingers) | +`base_link`, `base_pose`, and `end_effector_link` are deprecated planning-level +fields. New planning code should use SRDF/planning-group chain base/tip links +and encode robot placement in the model. See +[Planning Groups](/docs/capabilities/manipulation/planning_groups.md). + ## Step 5: Register Blueprints The blueprint registry in `dimos/robot/all_blueprints.py` is **auto-generated** by scanning the codebase for blueprint declarations. After adding your blueprints: diff --git a/docs/capabilities/manipulation/planning_groups.md b/docs/capabilities/manipulation/planning_groups.md new file mode 100644 index 0000000000..f5ab9cecd3 --- /dev/null +++ b/docs/capabilities/manipulation/planning_groups.md @@ -0,0 +1,121 @@ +# Manipulation Planning Groups + +Planning groups are named, selectable kinematic chains used by manipulation +planning. They separate the hardware robot identity from the part of the robot +being planned. + +## Concepts + +| Concept | Meaning | +|---------|---------| +| Planning group | A named serial chain of controllable robot joints. | +| Planning group ID | Stable API ID in the form `{robot_name}/{group_name}`. | +| Resolved joint name | World-level joint name in the form `{robot_name}/{local_joint_name}`. | +| Generated plan | Minimal planning artifact containing selected group IDs and one synchronized resolved-joint path. | +| Auxiliary group | A group selected for a pose request without receiving its own pose target. | + +Local URDF/SRDF joint names stay inside model parsing and backend internals. +Public planning states and generated plan paths use resolved joint names so two +robots can safely have the same local joint names. + +## Planning group sources + +DimOS discovers planning groups in this order: + +1. Explicit `srdf_path` on `RobotConfig` / `RobotModelConfig`. +2. Conservative SRDF auto-discovery near the model path, with a visible warning. +3. Fallback generation of one `{robot_name}/manipulator` group if the configured + controllable joints form exactly one unambiguous serial chain. +4. Error if no SRDF exists and fallback cannot infer a single chain. + +Supported SRDF group forms: + +```xml + + + +``` + +```xml + + + + + +``` + +Unsupported SRDF forms are skipped with warnings: link groups, nested group +references, mixed group declarations, branching/non-serial groups, and SRDF +`` metadata. A chain group's `tip_link` is the pose target frame. +An ordered joint-list group may be pose-targeted only when DimOS can validate a +unique serial target frame. + +## Fallback behavior + +When no SRDF is available, fallback uses `RobotModelConfig.joint_names` as the +candidate controllable set. This field is the robot's controllable/coordinator +joint set, not an implicit planning group. + +Fallback succeeds only when those joints form one unambiguous serial chain. It +allows prismatic joints in the middle of the chain and strips only terminal/tip +prismatic joints, which usually represent gripper fingers. The generated group +name is always `manipulator`. + +## Planning APIs + +Planning APIs select groups explicitly. Descriptors returned by +`WorldSpec.list_planning_groups()` can be passed where a group ID is accepted; +the API normalizes them back to IDs and re-resolves current world state. + +```python skip +# Joint-space planning for one group. +manip.plan_to_joint_targets({ + "left_arm/manipulator": JointState( + name=["left_arm/joint1", "left_arm/joint2"], + position=[0.2, -0.1], + ) +}) + +# Pose planning for an arm while a torso/waist group participates as free DOFs. +manip.plan_to_poses( + {"robot/arm": target_pose}, + auxiliary_groups=["robot/torso"], +) + +plan = manip._last_plan +manip.preview_plan(plan) +manip.execute_plan(plan) +``` + +For joint-space planning, start and goal joint keys must exactly match the +selected resolved joints: no missing, extra, or partial joints. + +## Generated plans and execution + +A `GeneratedPlan` stores: + +- selected planning group IDs; +- a single synchronized path of `JointState` waypoints keyed by resolved joint + names; +- status, timing, path length, iteration count, and message metadata. + +Preview and execution project this path lazily. Preview sends projected joint +paths to the world monitor. Execution splits the path by affected coordinator +trajectory task, orders each trajectory by that task's configured joint order, +maps resolved/local names to coordinator names at the boundary, and invokes each +trajectory controller. Controllers remain planning-group agnostic. + +Multi-task dispatch is not atomic in this change: if one trajectory task accepts +and a later task rejects, DimOS reports the rejection but does not roll back the +accepted task. + +## Deprecated planning config fields + +`RobotConfig.base_link`, `RobotConfig.base_pose`, +`RobotModelConfig.base_link`, `RobotModelConfig.base_pose`, and +`RobotModelConfig.end_effector_link` remain as compatibility fields for the +current Drake weld and deprecated robot-scoped FK/Jacobian APIs. New planning +logic should use model/SRDF structure and planning group base/tip links instead. + +Robot placement should be encoded in URDF/xacro/MJCF. `joint_names` remains +supported and should describe the controllable/coordinator joint set. diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index c9cc01cd42..26166acbc6 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -114,6 +114,14 @@ visualization backend. [guide is here](/docs/capabilities/manipulation/adding_a_custom_arm.md) +## Planning Groups + +Manipulation planning uses explicit planning group IDs such as +`arm/manipulator` and resolved joint names such as `arm/joint1`. See +[Planning Groups](/docs/capabilities/manipulation/planning_groups.md) for SRDF +support, fallback generation, auxiliary groups, generated plans, and execution +projection. + ## Key Files | File | Description | diff --git a/openspec/changes/add-planning-groups/docs.md b/openspec/changes/add-planning-groups/docs.md new file mode 100644 index 0000000000..1a8ad2b45b --- /dev/null +++ b/openspec/changes/add-planning-groups/docs.md @@ -0,0 +1,45 @@ +## User-Facing Docs + +- Update `docs/usage/` manipulation planning documentation to introduce planning groups as the user-facing planning selection unit. +- Document Planning Group IDs as `{robot_name}/{group_name}` and resolved joint names as `{robot_name}/{local_joint_name}`. +- Document supported SRDF forms: + - `` + - `...` when the joints validate as one serial chain. +- Document unsupported SRDF forms and warning behavior for skipped groups. +- Document fallback behavior for robots without SRDF: one generated `{robot_name}/manipulator` group only when configured controllable joints form one unambiguous serial chain. +- Document public planning API usage: + - pose targets keyed by planning group + - request-scoped `auxiliary_groups` + - joint targets keyed by planning group + - generated plans returned as the canonical artifact +- Document lazy preview/execution flow: generated plan first, then `preview_plan(plan)` and `execute_plan(plan)` project as needed. + +## Contributor Docs + +- Update `docs/development/` manipulation/planning contributor documentation, if present, to explain: + - SRDF/fallback extraction responsibilities + - local versus resolved joint-name layering + - where group resolution belongs + - why controllers should remain planning-group agnostic +- If no dedicated manipulation contributor doc exists, add the contributor notes to the user-facing manipulation planning doc or create a short development note for planning backends. + +## Coding-Agent Docs + +- Update `docs/coding-agents/` or `AGENTS.md` only if implementation introduces new recurring coding-agent guidance. +- Likely guidance: + - do not add robot-scoped planning APIs for new manipulation work + - use explicit Planning Group IDs in examples/tests + - keep local URDF/SRDF names below parsing/backend internals + - use resolved joint names in public paths/states + +## Doc Validation + +- Run doc link validation if available: + - `uv run doclinks` +- For docs containing executable Python snippets, run the relevant markdown execution command if supported: + - `uv run md-babel-py run ` +- Run relevant tests that exercise documented examples or API snippets after implementation. + +## No Docs Needed + +Documentation is needed. This change alters public manipulation planning concepts, API examples, naming conventions, SRDF support expectations, and migration guidance for existing callers. diff --git a/openspec/changes/add-planning-groups/specs/manipulation-planning-groups/spec.md b/openspec/changes/add-planning-groups/specs/manipulation-planning-groups/spec.md new file mode 100644 index 0000000000..603a392b0e --- /dev/null +++ b/openspec/changes/add-planning-groups/specs/manipulation-planning-groups/spec.md @@ -0,0 +1,216 @@ +## ADDED Requirements + +### Requirement: Planning group discovery + +DimOS SHALL expose manipulation planning groups discovered from supported SRDF group declarations or from conservative fallback generation when no SRDF is available. + +#### Scenario: supported SRDF chain group is discovered +- **GIVEN** a robot model configuration references an SRDF containing a `` with one `` +- **WHEN** planning groups are listed for that robot +- **THEN** the chain group is available as a planning group +- **AND** its public Planning Group ID is `{robot_name}/{group_name}` + +#### Scenario: supported SRDF joint-list group is discovered +- **GIVEN** a robot model configuration references an SRDF containing a `` with an ordered list of `` entries +- **AND** those joints validate as one serial chain +- **WHEN** planning groups are listed for that robot +- **THEN** the joint-list group is available as a planning group + +#### Scenario: unsupported SRDF groups are skipped +- **GIVEN** an SRDF contains unsupported group forms such as link groups, nested group references, mixed forms, or non-serial groups +- **WHEN** planning groups are discovered +- **THEN** unsupported groups are skipped with warnings +- **AND** supported groups from the same SRDF remain available + +#### Scenario: fallback group is generated for unambiguous single-chain robot +- **GIVEN** a robot has no SRDF +- **AND** its configured controllable joints form exactly one unambiguous serial chain +- **WHEN** planning groups are listed for that robot +- **THEN** DimOS exposes one generated planning group named `manipulator` +- **AND** the group ID is `{robot_name}/manipulator` + +#### Scenario: ambiguous fallback fails +- **GIVEN** a robot has no SRDF +- **AND** its configured controllable joints are branching, disconnected, ambiguous, or otherwise not one serial chain +- **WHEN** planning groups are discovered +- **THEN** DimOS fails with an error requiring SRDF rather than silently creating an implicit group + +### Requirement: Planning group descriptors + +DimOS SHALL provide read-only planning group descriptors that identify available groups without acting as live runtime handles. + +#### Scenario: descriptor includes stable identity +- **GIVEN** a planning group exists for a robot +- **WHEN** a caller lists planning groups +- **THEN** the returned descriptor includes the Planning Group ID +- **AND** the Planning Group ID is stable and namespaced as `{robot_name}/{group_name}` + +#### Scenario: descriptor can be used as selector +- **GIVEN** a caller receives a planning group descriptor from a query API +- **WHEN** the caller passes that descriptor to a planning API +- **THEN** DimOS selects the group by descriptor ID +- **AND** DimOS re-resolves current runtime group data instead of trusting stale descriptor fields + +### Requirement: Resolved joint naming + +DimOS SHALL expose resolved joint names above the model parsing layer using `{robot_name}/{local_joint_name}`. + +#### Scenario: generated path uses resolved names +- **GIVEN** a robot named `left` has a local model joint named `joint1` +- **WHEN** DimOS returns a planning result path that includes that joint +- **THEN** the path uses `left/joint1` as the joint name +- **AND** the bare local name `joint1` is not exposed in the public path + +#### Scenario: duplicate local names remain unambiguous +- **GIVEN** two robots both contain a local model joint named `joint1` +- **WHEN** a coordinated plan includes both joints +- **THEN** the plan distinguishes them with resolved names such as `left/joint1` and `right/joint1` + +### Requirement: Planning group selection validation + +DimOS SHALL validate that selected planning groups are known and do not overlap in resolved joints. + +#### Scenario: non-overlapping groups are accepted +- **GIVEN** a planning request selects two known planning groups with disjoint resolved joints +- **WHEN** DimOS resolves the planning group selection +- **THEN** the selection is accepted +- **AND** the effective selected joint set is the union of both groups' resolved joints + +#### Scenario: overlapping groups are rejected +- **GIVEN** a planning request selects two planning groups that share at least one resolved joint +- **WHEN** DimOS resolves the planning group selection +- **THEN** the request fails before planning begins +- **AND** the error identifies overlapping selected joints or groups + +#### Scenario: unknown group is rejected +- **GIVEN** a planning request references a Planning Group ID that is not available +- **WHEN** DimOS resolves the planning group selection +- **THEN** the request fails with an unknown planning group error + +### Requirement: Pose planning with auxiliary groups + +DimOS SHALL support pose planning with pose-targeted groups and request-scoped auxiliary groups that contribute free degrees of freedom. + +#### Scenario: auxiliary torso helps arm pose planning +- **GIVEN** a pose planning request targets `robot/arm` +- **AND** the request includes `robot/torso` as an auxiliary group +- **WHEN** DimOS solves IK and planning for the request +- **THEN** the effective planning selection includes both arm and torso joints +- **AND** the arm pose target is enforced +- **AND** torso joints are free decision variables with no direct pose constraint in that request + +#### Scenario: auxiliary status is request-scoped +- **GIVEN** a planning group has a valid pose target frame +- **WHEN** that group is listed in `auxiliary_groups` for a pose planning request +- **THEN** DimOS treats it as unconstrained by pose for that request +- **AND** the same group may be directly pose-targeted in a different request + +#### Scenario: pose-targeted group without target frame is rejected +- **GIVEN** a planning group has no valid pose target frame +- **WHEN** a caller uses that group as a key in pose targets +- **THEN** DimOS rejects the request before planning begins + +### Requirement: Joint target planning exactness + +DimOS SHALL require joint target planning requests to provide exact selected resolved joint keys. + +#### Scenario: exact joint target is accepted +- **GIVEN** a joint target request selects a planning group with resolved joints `robot/joint1` and `robot/joint2` +- **WHEN** the request provides target values for exactly `robot/joint1` and `robot/joint2` +- **THEN** DimOS accepts the target for planning + +#### Scenario: missing joint target is rejected +- **GIVEN** a joint target request selects a planning group with resolved joints `robot/joint1` and `robot/joint2` +- **WHEN** the request provides a target for only `robot/joint1` +- **THEN** DimOS rejects the request as incomplete + +#### Scenario: extra joint target is rejected +- **GIVEN** a joint target request selects a planning group with resolved joints `robot/joint1` and `robot/joint2` +- **WHEN** the request also includes `robot/joint3` +- **THEN** DimOS rejects the request because the target contains joints outside the selected planning groups + +### Requirement: IK result shape + +DimOS SHALL return IK solutions containing exactly the resolved joints selected by the effective planning group selection. + +#### Scenario: IK result excludes unrelated joints +- **GIVEN** a pose request targets `robot/arm` and includes `robot/torso` as auxiliary +- **WHEN** IK succeeds +- **THEN** the IK solution contains arm and torso resolved joints +- **AND** it excludes unrelated gripper, base, or other-arm joints that were not selected + +#### Scenario: IK solves over auxiliary joints +- **GIVEN** a pose request has auxiliary groups +- **WHEN** IK attempts to satisfy pose targets +- **THEN** auxiliary group joints are available as free variables during the solve + +### Requirement: Generated plan artifact + +DimOS SHALL return a generated plan as the canonical artifact for successful planning. + +#### Scenario: generated plan contains selected groups and combined path +- **GIVEN** a planning request succeeds for one or more planning groups +- **WHEN** DimOS returns the generated plan +- **THEN** the plan includes the selected Planning Group IDs +- **AND** the plan includes one synchronized path of resolved-joint-keyed joint states + +#### Scenario: generated plan path contains exactly selected joints +- **GIVEN** a generated plan was produced for a planning group selection +- **WHEN** a caller inspects any waypoint in the path +- **THEN** that waypoint contains exactly the selected resolved joints +- **AND** unrelated joints are excluded + +#### Scenario: generated plan is reusable for downstream calls +- **GIVEN** a caller receives a generated plan +- **WHEN** the caller previews or executes the plan +- **THEN** DimOS uses the generated plan as the source artifact +- **AND** the caller does not need hidden robot-keyed planned path state + +### Requirement: Preview and execution projection + +DimOS SHALL project generated plans lazily for preview and execution without making controllers planning-group-aware. + +#### Scenario: preview projects selected path +- **GIVEN** a generated plan contains a resolved-joint path +- **WHEN** a caller previews the plan +- **THEN** DimOS projects the path into visualization using the selected joints +- **AND** preview does not require a precomputed execution trajectory stored in the plan + +#### Scenario: execution projects per trajectory task +- **GIVEN** a generated plan contains resolved joints for one or more coordinator trajectory tasks +- **WHEN** a caller executes the plan +- **THEN** DimOS projects the combined path into one trajectory per affected task +- **AND** each trajectory is ordered according to that task's configured joint order +- **AND** planning group concepts are not exposed to the trajectory controller + +#### Scenario: multi-task execution is dispatched without batch atomicity +- **GIVEN** a generated plan affects multiple trajectory tasks with disjoint joints +- **WHEN** the plan is executed +- **THEN** DimOS dispatches projected trajectories to the affected tasks +- **AND** runtime concurrency is handled by the coordinator and trajectory tasks +- **AND** DimOS does not require an atomic batch dispatch for this change + +### Requirement: Group-scoped kinematics queries + +DimOS SHALL provide group-scoped pose and Jacobian queries for planning groups with valid pose target frames. + +#### Scenario: group pose query succeeds for chain group +- **GIVEN** a planning group has a valid tip or pose target frame +- **WHEN** a caller requests the group's pose +- **THEN** DimOS returns the pose for that group target frame + +#### Scenario: group pose query fails without target frame +- **GIVEN** a planning group has no valid pose target frame +- **WHEN** a caller requests the group's pose or Jacobian +- **THEN** DimOS fails with a clear error rather than guessing a frame + +### Requirement: Backend unsupported reporting + +DimOS SHALL allow planning backends to reject coordinated planning problems they cannot support. + +#### Scenario: cross-robot planning unsupported by backend +- **GIVEN** a request selects planning groups across multiple robots +- **AND** the active backend cannot solve cross-robot coordinated planning +- **WHEN** planning is requested +- **THEN** DimOS reports the request as unsupported +- **AND** the failure occurs without sending commands to trajectory controllers diff --git a/openspec/changes/add-planning-groups/tasks.md b/openspec/changes/add-planning-groups/tasks.md new file mode 100644 index 0000000000..b706372e11 --- /dev/null +++ b/openspec/changes/add-planning-groups/tasks.md @@ -0,0 +1,92 @@ +## 1. Planning group data model and parsing + +- [x] 1.1 Add planning group model types for definitions, descriptors, resolved groups, and generated plans. +- [x] 1.2 Add `srdf_path` to robot/model configuration and carry it through model-to-planning config conversion. +- [x] 1.3 Implement SRDF parsing for supported `` declarations. +- [x] 1.4 Implement SRDF parsing for ordered `...` declarations that validate as one serial chain. +- [x] 1.5 Emit warnings and skip unsupported SRDF groups, including link groups, nested group references, mixed forms, and non-serial groups. +- [x] 1.6 Ignore SRDF `` metadata for planning group extraction. +- [x] 1.7 Implement conservative SRDF auto-discovery with visible warning after explicit `srdf_path` lookup fails or is absent. +- [x] 1.8 Implement fallback generation of `{robot_name}/manipulator` from `RobotModelConfig.joint_names` when no SRDF is available. +- [x] 1.9 Validate fallback as exactly one unambiguous serial chain, allowing middle prismatic joints and excluding only terminal/tip prismatic finger joints. +- [x] 1.10 Add unit tests for SRDF chain groups, joint-list groups, skipped unsupported groups, and fallback success/failure cases. + +## 2. Naming and group resolution + +- [x] 2.1 Add deterministic helpers for local model joint names to resolved joint names: `{robot_name}/{local_joint_name}`. +- [x] 2.2 Add inverse validation/stripping helper for backend internals that need local joint names. +- [x] 2.3 Update public joint-state/path surfaces above model parsing to use resolved joint names. +- [x] 2.4 Add `WorldSpec.list_planning_groups()` and return immutable planning group descriptor snapshots. +- [x] 2.5 Add `WorldSpec.resolve_planning_groups(...)` and bind definitions to concrete robot/world data. +- [x] 2.6 Validate unknown group IDs during resolution. +- [x] 2.7 Validate selected groups never overlap in resolved joints. +- [x] 2.8 Update Drake world internals to map resolved joint names to local joint names and model instances. +- [x] 2.9 Add focused tests for descriptors, stable IDs, resolved names, duplicate local joint disambiguation, unknown groups, and overlap rejection. + +## 3. Group-scoped world and kinematics APIs + +- [x] 3.1 Add group-scoped pose query API for planning groups with valid pose target frames. +- [x] 3.2 Add group-scoped Jacobian query API for planning groups with valid pose target frames. +- [x] 3.3 Preserve lower-level link pose querying for explicit robot/link lookups. +- [x] 3.4 Remove or deprecate robot-scoped end-effector FK/Jacobian APIs. +- [x] 3.5 Update `KinematicsSpec` to solve pose targets keyed by planning group plus request-scoped auxiliary groups. +- [x] 3.6 Ensure IK solves over the full effective selection and treats auxiliary group joints as free variables. +- [x] 3.7 Ensure `IKResult.solution` contains exactly selected resolved joints and excludes unrelated joints. +- [x] 3.8 Add tests for pose-targeted groups, auxiliary groups, no-target-frame rejection, and selected-joint-only IK results. + +## 4. Planner APIs and generated plan flow + +- [x] 4.1 Update planner APIs to accept planning group selection / resolved selected joints instead of a single `robot_id` planning target. +- [x] 4.2 Enforce exact start and goal `JointState` keys for joint-space planning: no missing, extra, or partial joints. +- [x] 4.3 Implement pose planning lowering from pose targets plus auxiliary groups to IK goal and combined joint-space planning. +- [x] 4.4 Allow backends to report `UNSUPPORTED` for coordinated planning problems they cannot solve. +- [x] 4.5 Add `GeneratedPlan` as the canonical returned planning artifact with selected group IDs and combined resolved-joint path. +- [x] 4.6 Ensure every `GeneratedPlan.path` waypoint contains exactly selected resolved joints. +- [x] 4.7 Replace robot-keyed planned path and planned trajectory caches in `ManipulationModule` with optional `_last_plan` convenience state. +- [x] 4.8 Add tests for joint target exactness, pose planning result shape, multi-group synchronized paths, backend unsupported reporting, and generated plan reuse. + +## 5. Preview and execution projection + +- [x] 5.1 Update preview APIs to accept an explicit `GeneratedPlan` and optionally fall back to `_last_plan` convenience state. +- [x] 5.2 Project generated plan paths lazily into visualization/world monitor calls. +- [x] 5.3 Update execution APIs to accept an explicit `GeneratedPlan` and optionally fall back to `_last_plan` convenience state. +- [x] 5.4 Project generated plan paths lazily into one `JointTrajectory` per affected coordinator trajectory task. +- [x] 5.5 Order projected trajectory positions according to each task's configured joint order. +- [x] 5.6 Convert resolved joint names to coordinator joint names at the execution boundary when a mapping exists. +- [x] 5.7 Keep trajectory controllers and coordinator tasks planning-group agnostic. +- [x] 5.8 Add tests for single-task execution projection, multi-task execution projection, task joint ordering, and preview projection. + +## 6. Robot config migration and API cleanup + +- [x] 6.1 Remove or deprecate planning-level `RobotConfig.base_link` and `RobotConfig.base_pose` usage. +- [x] 6.2 Remove or deprecate planning-level `RobotModelConfig.base_link`, `RobotModelConfig.base_pose`, and `RobotModelConfig.end_effector_link` usage. +- [x] 6.3 Keep and document `RobotConfig.joint_names` and `RobotModelConfig.joint_names` as controllable/coordinator joint sets, not planning groups. +- [x] 6.4 Update existing robot catalog/config entries to use SRDF where needed or rely on fallback for unambiguous single-chain arms. +- [x] 6.5 Update manipulation skills/wrappers to select planning groups explicitly or provide clear wrapper-level defaults. +- [x] 6.6 Run `pytest dimos/robot/test_all_blueprints_generation.py` if implementation changes blueprint names or generated registry inputs. + +## 7. Documentation + +- [x] 7.1 Update user-facing manipulation planning docs with planning group concepts, IDs, resolved joint names, and API examples. +- [x] 7.2 Document supported and unsupported SRDF forms plus skipped-group warning behavior. +- [x] 7.3 Document fallback generation rules and failure behavior for ambiguous robots. +- [x] 7.4 Document pose targets, auxiliary groups, joint targets, generated plans, preview, and execution flow. +- [x] 7.5 Update contributor docs or add a development note covering group resolution ownership, local/resolved naming, and controller boundaries. +- [x] 7.6 Update coding-agent docs or `AGENTS.md` only if implementation introduces recurring guidance beyond the existing design docs. + +## 8. Verification + +- [x] 8.1 Run `openspec validate add-planning-groups`. +- [x] 8.2 Run focused tests for robot config/model parsing changes. +- [x] 8.3 Run focused tests for planning group SRDF/fallback parsing. +- [x] 8.4 Run focused tests for `WorldSpec`/Drake world group resolution and group-scoped queries. +- [x] 8.5 Run focused tests for IK and planner selected-joint semantics. +- [x] 8.6 Run focused tests for `ManipulationModule` generated plan, preview, and execution projection. +- [x] 8.7 Run broader manipulation/planning pytest targets touched by the implementation. +- [x] 8.8 Run type checks for changed planning/manipulation modules if feasible: `uv run mypy dimos/` or a narrower supported target. Attempted narrow mypy; unavailable in this environment (`mypy` executable missing). +- [x] 8.9 Run docs validation commands for changed docs, including `uv run doclinks` if available. +- [x] 8.10 Run markdown snippet validation with `uv run md-babel-py run ` for docs containing executable Python examples, if available. +- [x] 8.11 Manually QA a single-arm no-SRDF fallback planning flow in replay or simulation. Covered by `dimos/e2e_tests/test_manipulation_planning_groups.py::test_single_arm_plans_and_executes_through_control_coordinator` using `openarm-mock-planner-coordinator` under `self_hosted_large`. +- [ ] 8.12 Manually QA an SRDF-backed chain group flow if a test fixture or robot config is available. +- [ ] 8.13 Manually QA arm-plus-auxiliary-group pose planning in simulation/replay if a suitable model is available. +- [x] 8.14 Manually QA a dual-arm planning flow by launching a dual-arm planning example, preferably OpenArm if available or another suitable dual-arm robot stack, then using the manipulation client to initiate one coordinated plan that selects both arms' planning groups and verifies the generated plan contains both arms' resolved joint names. Covered by `dimos/e2e_tests/test_manipulation_planning_groups.py::test_dual_arm_plans_and_dispatches_both_arms_through_control_coordinator` using `openarm-mock-planner-coordinator` under `self_hosted_large`. diff --git a/pyproject.toml b/pyproject.toml index 6806733e06..ad915f4b1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -612,6 +612,7 @@ ignore = [ "dimos/dashboard/dimos.rbl", "dimos/web/dimos_interface/themes.json", "dimos/manipulation/manipulation_module.py", + "dimos/manipulation/planning/world/drake_world.py", "dimos/navigation/nav_stack/modules/*/main.cpp", "dimos/navigation/nav_stack/common/*.hpp", ] From 3744ec7580aaf5fe05511116ad3e1c1aaa50e6e9 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 17 Jun 2026 18:28:55 -0700 Subject: [PATCH 035/110] fix: address planning group review feedback --- .../test_manipulation_planning_groups.py | 7 -- dimos/manipulation/manipulation_module.py | 22 ++---- .../planning/kinematics/jacobian_ik.py | 62 +++++++-------- .../planning/kinematics/pink_ik.py | 53 ++++--------- .../kinematics/test_jacobian_ik_selection.py | 22 ++++-- .../planning/monitor/world_monitor.py | 47 +++++------ .../planning/planning_group_utils.py | 78 +++++++++++++++++++ .../{names.py => planning_identifiers.py} | 24 +++--- .../planning/world/drake_world.py | 26 ++++--- 9 files changed, 193 insertions(+), 148 deletions(-) create mode 100644 dimos/manipulation/planning/planning_group_utils.py rename dimos/manipulation/planning/{names.py => planning_identifiers.py} (83%) diff --git a/dimos/e2e_tests/test_manipulation_planning_groups.py b/dimos/e2e_tests/test_manipulation_planning_groups.py index 70ae660b1a..24b526e24e 100644 --- a/dimos/e2e_tests/test_manipulation_planning_groups.py +++ b/dimos/e2e_tests/test_manipulation_planning_groups.py @@ -22,7 +22,6 @@ from __future__ import annotations from collections.abc import Callable -import importlib.util import time from typing import Any @@ -42,10 +41,6 @@ BLUEPRINT = "openarm-mock-planner-coordinator" -def _drake_available() -> bool: - return importlib.util.find_spec("pydrake") is not None - - def _wait_for_robot_info( client: RPCClient, robot_name: str, @@ -150,7 +145,6 @@ def _start_openarm_mock_planner( lcm_spy.wait_for_saved_topic(JOINT_STATE_TOPIC, timeout=120.0) -@pytest.mark.skipif(not _drake_available(), reason="Drake not installed") def test_single_arm_plans_and_executes_through_control_coordinator( lcm_spy: LcmSpy, start_blueprint: Callable[..., DimosCliCall], @@ -180,7 +174,6 @@ def test_single_arm_plans_and_executes_through_control_coordinator( client.stop_rpc_client() -@pytest.mark.skipif(not _drake_available(), reason="Drake not installed") def test_dual_arm_plans_and_dispatches_both_arms_through_control_coordinator( lcm_spy: LcmSpy, start_blueprint: Callable[..., DimosCliCall], diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index bd796fbf7e..2ceccb7feb 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -28,6 +28,7 @@ from enum import Enum import threading import time +import traceback from typing import TYPE_CHECKING, Any, TypeAlias import numpy as np @@ -46,7 +47,7 @@ kinematics_config_from_name, ) from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor -from dimos.manipulation.planning.names import to_resolved_joint_name +from dimos.manipulation.planning.planning_identifiers import make_resolved_joint_name from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType from dimos.manipulation.planning.spec.models import ( @@ -66,7 +67,9 @@ ) from dimos.manipulation.skill_errors import ManipulationSkillError from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Transform import Transform from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.sensor_msgs.JointState import JointState from dimos.msgs.trajectory_msgs.JointTrajectory import JointTrajectory @@ -318,14 +321,10 @@ def _on_joint_state(self, msg: JointState) -> None: except Exception as e: logger.error(f"Exception in _on_joint_state: {e}") - import traceback - logger.error(traceback.format_exc()) def _tf_publish_loop(self) -> None: """Publish TF transforms at 10Hz for EE and extra links.""" - from dimos.msgs.geometry_msgs.Transform import Transform - period = 0.1 # 10Hz while not self._tf_stop_event.is_set(): try: @@ -576,7 +575,7 @@ def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotNam """ robot_id, config, _ = self._robots[robot_name] resolved_joint_names = [ - to_resolved_joint_name(robot_name, joint) for joint in config.joint_names + make_resolved_joint_name(robot_name, joint) for joint in config.joint_names ] current_by_name: dict[str, float] = {} if self._world_monitor is not None: @@ -728,9 +727,6 @@ def _solve_ik_for_pose( """Run the configured kinematics backend for a world-frame pose.""" assert self._world_monitor and self._kinematics - # Convert Pose to PoseStamped for the IK solver - from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped - target_pose = PoseStamped( frame_id="world", position=pose.position, @@ -804,9 +800,6 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: if current is None: return self._fail("No joint state") - # Convert Pose to PoseStamped for the IK solver - from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped - target_pose = PoseStamped( frame_id="world", position=pose.position, @@ -854,8 +847,6 @@ def plan_to_poses( return False self._state = ManipulationState.PLANNING - from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped - stamped_targets = { self._normalize_group_selector(group): PoseStamped( frame_id="world", @@ -1420,9 +1411,6 @@ def add_obstacle( logger.warning("mesh_path required for mesh obstacles") return "" - # Import PoseStamped here to avoid circular imports - from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped - obstacle = Obstacle( name=name, obstacle_type=obstacle_type, diff --git a/dimos/manipulation/planning/kinematics/jacobian_ik.py b/dimos/manipulation/planning/kinematics/jacobian_ik.py index 7c810d6302..afdef5d5d0 100644 --- a/dimos/manipulation/planning/kinematics/jacobian_ik.py +++ b/dimos/manipulation/planning/kinematics/jacobian_ik.py @@ -26,9 +26,14 @@ from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING +import warnings import numpy as np +from dimos.manipulation.planning.planning_group_utils import ( + filter_joint_state_to_selected_joints, + planning_group_id_from_selector, +) from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import ( IKResult, @@ -59,7 +64,11 @@ class JacobianIK: - """Backend-agnostic Jacobian-based IK solver. + """Deprecated backend-agnostic Jacobian-based IK solver. + + Prefer PinkIK or DrakeOptimizationIK for new planning-group-aware code. + This class is retained as a compatibility/smoke-test backend and only + supports one directly pose-targeted planning group with no auxiliary groups. This class provides iterative and differential IK methods using only the standard WorldSpec interface. It works with any physics backend @@ -95,6 +104,11 @@ def __init__( max_iterations: Default maximum iterations for iterative IK singularity_threshold: Manipulability threshold for singularity detection """ + warnings.warn( + "JacobianIK is deprecated; use PinkIK or DrakeOptimizationIK for new code.", + DeprecationWarning, + stacklevel=2, + ) self._damping = damping self._max_iterations = max_iterations self._singularity_threshold = singularity_threshold @@ -204,30 +218,27 @@ def solve_pose_targets( ) -> IKResult: """Solve pose targets keyed by planning group with request-scoped auxiliaries. - This backend currently supports one directly pose-targeted robot at a time. Auxiliary - groups are included in the selected result shape when they belong to the same resolved - planning problem; existing robot-scoped IK provides their seed/current values as free - degrees of freedom. + This backend currently supports exactly one directly pose-targeted planning group. """ if not pose_targets: return _create_failure_result( IKStatus.NO_SOLUTION, "At least one pose target is required" ) - pose_group_ids = tuple(_selector_id(group) for group in pose_targets.keys()) - auxiliary_group_ids = tuple(_selector_id(group) for group in auxiliary_groups) - selected_group_ids = pose_group_ids + auxiliary_group_ids - try: - resolved_groups = world.resolve_planning_groups(selected_group_ids) - except (KeyError, ValueError) as exc: - return _create_failure_result(IKStatus.NO_SOLUTION, str(exc)) - - if len(pose_group_ids) != 1: + pose_group_ids = tuple( + planning_group_id_from_selector(group) for group in pose_targets.keys() + ) + if len(pose_group_ids) != 1 or auxiliary_groups: return _create_failure_result( IKStatus.NO_SOLUTION, - "JacobianIK supports exactly one pose target per request", + "JacobianIK supports exactly one pose target and no auxiliary planning groups", ) + try: + resolved_groups = world.resolve_planning_groups(pose_group_ids) + except (KeyError, ValueError) as exc: + return _create_failure_result(IKStatus.NO_SOLUTION, str(exc)) + target_group = next(group for group in resolved_groups if group.id == pose_group_ids[0]) if not target_group.has_pose_target: return _create_failure_result( @@ -265,7 +276,9 @@ def solve_pose_targets( for group in resolved_groups: selected_joint_names.extend(group.joint_names) try: - result.joint_state = _filter_joint_state(result.joint_state, selected_joint_names) + result.joint_state = filter_joint_state_to_selected_joints( + result.joint_state, selected_joint_names + ) except ValueError as exc: return _create_failure_result(IKStatus.NO_SOLUTION, str(exc)) return result @@ -506,23 +519,6 @@ def _create_success_result( ) -def _selector_id(selector: PlanningGroupID | PlanningGroupDescriptor) -> PlanningGroupID: - if isinstance(selector, PlanningGroupDescriptor): - return selector.id - return selector - - -def _filter_joint_state(joint_state: JointState, joint_names: list[str]) -> JointState: - positions_by_name = dict(zip(joint_state.name, joint_state.position, strict=False)) - missing = [joint_name for joint_name in joint_names if joint_name not in positions_by_name] - if missing: - raise ValueError(f"IK result is missing selected joints: {missing}") - return JointState( - name=joint_names, - position=[positions_by_name[joint_name] for joint_name in joint_names], - ) - - def _create_failure_result( status: IKStatus, message: str, diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index 6ea356e87b..86a8d04241 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -26,6 +26,11 @@ import numpy as np from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig +from dimos.manipulation.planning.planning_group_utils import ( + filter_joint_state_to_selected_joints, + matching_resolved_joint_name, + planning_group_id_from_selector, +) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import ( @@ -185,8 +190,12 @@ def solve_pose_targets( if not pose_targets: return _failure(IKStatus.NO_SOLUTION, "At least one pose target is required") - pose_group_ids = tuple(_selector_id(group) for group in pose_targets.keys()) - auxiliary_group_ids = tuple(_selector_id(group) for group in auxiliary_groups) + pose_group_ids = tuple( + planning_group_id_from_selector(group) for group in pose_targets.keys() + ) + auxiliary_group_ids = tuple( + planning_group_id_from_selector(group) for group in auxiliary_groups + ) selected_group_ids = pose_group_ids + auxiliary_group_ids try: resolved_groups = world.resolve_planning_groups(selected_group_ids) @@ -259,7 +268,7 @@ def solve_pose_targets( selected_joint_names.extend(group.joint_names) selected_local_names.extend(group.local_joint_names) try: - result.joint_state = _filter_and_resolve_joint_state( + result.joint_state = filter_joint_state_to_selected_joints( result.joint_state, selected_joint_names, selected_local_names, @@ -553,7 +562,7 @@ def _seed_positions_for_mapping(seed: JointState, mapping: _JointMapping) -> NDA elif model_name in positions_by_name: values.append(float(positions_by_name[model_name])) elif ( - resolved_name := _matching_resolved_name(positions_by_name, dimos_name) + resolved_name := matching_resolved_joint_name(positions_by_name, dimos_name) ) is not None: values.append(float(positions_by_name[resolved_name])) else: @@ -567,42 +576,6 @@ def _seed_positions_for_mapping(seed: JointState, mapping: _JointMapping) -> NDA return np.array(seed.position, dtype=np.float64) -def _selector_id(selector: PlanningGroupID | PlanningGroupDescriptor) -> PlanningGroupID: - if isinstance(selector, PlanningGroupDescriptor): - return selector.id - return selector - - -def _matching_resolved_name( - positions_by_name: Mapping[str, float], local_joint_name: str -) -> str | None: - suffix = f"/{local_joint_name}" - matches = [name for name in positions_by_name if name.endswith(suffix)] - if len(matches) == 1: - return matches[0] - return None - - -def _filter_and_resolve_joint_state( - joint_state: JointState, - resolved_joint_names: list[str], - local_joint_names: list[str], -) -> JointState: - if len(resolved_joint_names) != len(local_joint_names): - raise ValueError("Resolved and local selected joint lists must have the same length") - - positions_by_name = dict(zip(joint_state.name, joint_state.position, strict=True)) - selected_positions: list[float] = [] - for resolved_name, local_name in zip(resolved_joint_names, local_joint_names, strict=True): - if resolved_name in positions_by_name: - selected_positions.append(float(positions_by_name[resolved_name])) - elif local_name in positions_by_name: - selected_positions.append(float(positions_by_name[local_name])) - else: - raise ValueError(f"IK result is missing selected joint '{resolved_name}'") - return JointState({"name": resolved_joint_names, "position": selected_positions}) - - def _matrix_to_se3(pinocchio: ModuleType, matrix: NDArray[np.float64]) -> Any: return pinocchio.SE3(matrix[:3, :3], matrix[:3, 3]) diff --git a/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py b/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py index a013af34ac..fd9b2a9e54 100644 --- a/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py +++ b/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py @@ -81,25 +81,37 @@ def solve( ) -def test_solve_pose_targets_filters_result_to_target_and_auxiliary_joints() -> None: +def test_solve_pose_targets_filters_result_to_single_group_joints() -> None: world = _IKWorld( { "arm/arm": _group("arm/arm", ("arm/joint1", "arm/joint2")), - "arm/gripper": _group("arm/gripper", ("arm/gripper",), tip_link=None), } ) result = _SuccessfulIK().solve_pose_targets( world=cast("WorldSpec", world), pose_targets={"arm/arm": _pose()}, - auxiliary_groups=["arm/gripper"], seed=_joint_state(["arm/joint1", "arm/joint2", "arm/gripper"], [0.0, 0.0, 0.0]), ) assert result.status == IKStatus.SUCCESS assert result.joint_state is not None - assert result.joint_state.name == ["arm/joint1", "arm/joint2", "arm/gripper"] - assert result.joint_state.position == [0.1, 0.2, 0.3] + assert result.joint_state.name == ["arm/joint1", "arm/joint2"] + assert result.joint_state.position == [0.1, 0.2] + + +def test_solve_pose_targets_rejects_auxiliary_groups() -> None: + world = _IKWorld({"arm/arm": _group("arm/arm", ("arm/joint1", "arm/joint2"))}) + + result = _SuccessfulIK().solve_pose_targets( + world=cast("WorldSpec", world), + pose_targets={"arm/arm": _pose()}, + auxiliary_groups=["arm/gripper"], + seed=_joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]), + ) + + assert result.status == IKStatus.NO_SOLUTION + assert "no auxiliary planning groups" in result.message def test_solve_pose_targets_rejects_group_without_pose_target_frame() -> None: diff --git a/dimos/manipulation/planning/monitor/world_monitor.py b/dimos/manipulation/planning/monitor/world_monitor.py index 92e067b4b6..08dc5421e2 100644 --- a/dimos/manipulation/planning/monitor/world_monitor.py +++ b/dimos/manipulation/planning/monitor/world_monitor.py @@ -26,6 +26,7 @@ from dimos.manipulation.planning.monitor.world_obstacle_monitor import WorldObstacleMonitor from dimos.manipulation.planning.spec.protocols import VisualizationSpec from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.sensor_msgs.JointState import JointState from dimos.utils.logging_config import setup_logger @@ -358,19 +359,11 @@ def get_min_distance(self, robot_id: WorldRobotID) -> float: def get_ee_pose( self, robot_id: WorldRobotID, joint_state: JointState | None = None ) -> PoseStamped: - """Get end-effector pose. Uses current state if joint_state is None. - - Deprecated: use get_group_pose() with an explicit planning group ID. - """ - with self._world.scratch_context() as ctx: - # If no state provided, fetch current from state monitor - if joint_state is None: - joint_state = self.get_current_joint_state(robot_id) - - if joint_state is not None: - self._world.set_joint_state(ctx, robot_id, joint_state) - - return self._world.get_ee_pose(ctx, robot_id) + """Get end-effector pose for the robot's unique pose-targetable group.""" + return self.get_group_pose( + self._unique_pose_group_id_for_robot(robot_id), + joint_state=joint_state, + ) def get_group_pose( self, group_id: PlanningGroupID, joint_state: JointState | None = None @@ -395,8 +388,6 @@ def get_link_pose( link_name: Name of the link in the URDF joint_state: Joint state to use (uses current if None) """ - from dimos.msgs.geometry_msgs.Quaternion import Quaternion - with self._world.scratch_context() as ctx: if joint_state is None: joint_state = self.get_current_joint_state(robot_id) @@ -418,13 +409,11 @@ def get_link_pose( ) def get_jacobian(self, robot_id: WorldRobotID, joint_state: JointState) -> NDArray[np.float64]: - """Get robot-scoped 6xN Jacobian matrix. - - Deprecated: use get_group_jacobian() with an explicit planning group ID. - """ - with self._world.scratch_context() as ctx: - self._world.set_joint_state(ctx, robot_id, joint_state) - return self._world.get_jacobian(ctx, robot_id) + """Get 6xN Jacobian for the robot's unique pose-targetable group.""" + return self.get_group_jacobian( + self._unique_pose_group_id_for_robot(robot_id), + joint_state=joint_state, + ) def get_group_jacobian( self, group_id: PlanningGroupID, joint_state: JointState @@ -435,6 +424,20 @@ def get_group_jacobian( self._world.set_joint_state(ctx, group.robot_id, joint_state) return self._world.get_group_jacobian(ctx, group_id) + def _unique_pose_group_id_for_robot(self, robot_id: WorldRobotID) -> PlanningGroupID: + robot_name = self._world.get_robot_config(robot_id).name + pose_group_ids = [ + group.id + for group in self._world.list_planning_groups() + if group.robot_name == robot_name and group.has_pose_target + ] + if len(pose_group_ids) != 1: + raise ValueError( + f"Robot '{robot_name}' has {len(pose_group_ids)} pose-targetable planning groups; " + "call get_group_pose/get_group_jacobian with an explicit planning group ID" + ) + return pose_group_ids[0] + # Lifecycle def finalize(self) -> None: diff --git a/dimos/manipulation/planning/planning_group_utils.py b/dimos/manipulation/planning/planning_group_utils.py new file mode 100644 index 0000000000..2b2b42ad81 --- /dev/null +++ b/dimos/manipulation/planning/planning_group_utils.py @@ -0,0 +1,78 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared helpers for planning-group selector and joint-state projection.""" + +from collections.abc import Mapping, Sequence + +from dimos.manipulation.planning.spec.models import ( + LocalModelJointName, + PlanningGroupDescriptor, + PlanningGroupID, + ResolvedJointName, +) +from dimos.msgs.sensor_msgs.JointState import JointState + + +def planning_group_id_from_selector( + selector: PlanningGroupID | PlanningGroupDescriptor, +) -> PlanningGroupID: + """Return the planning-group ID represented by a selector.""" + if isinstance(selector, PlanningGroupDescriptor): + return selector.id + return selector + + +def matching_resolved_joint_name( + positions_by_name: Mapping[str, float], local_joint_name: LocalModelJointName +) -> ResolvedJointName | None: + """Find the unique resolved joint name ending with a local joint name.""" + suffix = f"/{local_joint_name}" + matches = [name for name in positions_by_name if name.endswith(suffix)] + if len(matches) == 1: + return matches[0] + return None + + +def filter_joint_state_to_selected_joints( + joint_state: JointState, + resolved_joint_names: Sequence[ResolvedJointName], + local_joint_names: Sequence[LocalModelJointName] = (), +) -> JointState: + """Project a joint state to selected resolved joints. + + Values are looked up by resolved name first. When ``local_joint_names`` is + provided, each corresponding local name is used as a fallback. + """ + if local_joint_names and len(resolved_joint_names) != len(local_joint_names): + raise ValueError("Resolved and local selected joint lists must have the same length") + + positions_by_name = dict(zip(joint_state.name, joint_state.position, strict=True)) + selected_positions: list[float] = [] + missing: list[str] = [] + for index, resolved_name in enumerate(resolved_joint_names): + if resolved_name in positions_by_name: + selected_positions.append(float(positions_by_name[resolved_name])) + continue + if local_joint_names: + local_name = local_joint_names[index] + if local_name in positions_by_name: + selected_positions.append(float(positions_by_name[local_name])) + continue + missing.append(resolved_name) + + if missing: + raise ValueError(f"IK result is missing selected joints: {missing}") + + return JointState({"name": list(resolved_joint_names), "position": selected_positions}) diff --git a/dimos/manipulation/planning/names.py b/dimos/manipulation/planning/planning_identifiers.py similarity index 83% rename from dimos/manipulation/planning/names.py rename to dimos/manipulation/planning/planning_identifiers.py index 1b105eec56..afbc473ed5 100644 --- a/dimos/manipulation/planning/names.py +++ b/dimos/manipulation/planning/planning_identifiers.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Name helpers for planning/model/coordinator boundary layers.""" +"""Planning group and resolved-joint identifier helpers.""" from __future__ import annotations @@ -24,7 +24,7 @@ ) -def to_planning_group_id(robot_name: RobotName, group_name: str) -> PlanningGroupID: +def make_planning_group_id(robot_name: RobotName, group_name: str) -> PlanningGroupID: """Build a public planning group ID.""" if not robot_name or "/" in robot_name: raise ValueError(f"Invalid robot name for planning group ID: {robot_name!r}") @@ -33,7 +33,7 @@ def to_planning_group_id(robot_name: RobotName, group_name: str) -> PlanningGrou return f"{robot_name}/{group_name}" -def split_planning_group_id(group_id: PlanningGroupID) -> tuple[RobotName, str]: +def parse_planning_group_id(group_id: PlanningGroupID) -> tuple[RobotName, str]: """Split and validate a planning group ID.""" parts = group_id.split("/", maxsplit=1) if len(parts) != 2 or not parts[0] or not parts[1] or "/" in parts[1]: @@ -43,7 +43,7 @@ def split_planning_group_id(group_id: PlanningGroupID) -> tuple[RobotName, str]: return parts[0], parts[1] -def to_resolved_joint_name( +def make_resolved_joint_name( robot_name: RobotName, local_joint_name: LocalModelJointName, ) -> ResolvedJointName: @@ -55,15 +55,15 @@ def to_resolved_joint_name( return f"{robot_name}/{local_joint_name}" -def to_resolved_joint_names( +def make_resolved_joint_names( robot_name: RobotName, local_joint_names: list[LocalModelJointName] | tuple[LocalModelJointName, ...], ) -> list[ResolvedJointName]: """Convert local model joint names to public resolved joint names.""" - return [to_resolved_joint_name(robot_name, name) for name in local_joint_names] + return [make_resolved_joint_name(robot_name, name) for name in local_joint_names] -def strip_resolved_joint_name( +def local_joint_name_from_resolved( robot_name: RobotName, resolved_joint_name: ResolvedJointName, ) -> LocalModelJointName: @@ -80,9 +80,9 @@ def strip_resolved_joint_name( __all__ = [ - "split_planning_group_id", - "strip_resolved_joint_name", - "to_planning_group_id", - "to_resolved_joint_name", - "to_resolved_joint_names", + "local_joint_name_from_resolved", + "make_planning_group_id", + "make_resolved_joint_name", + "make_resolved_joint_names", + "parse_planning_group_id", ] diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 7d6181f65e..d0fb4d580e 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -25,12 +25,12 @@ import numpy as np -from dimos.manipulation.planning.names import ( - split_planning_group_id, - strip_resolved_joint_name, - to_planning_group_id, - to_resolved_joint_name, - to_resolved_joint_names, +from dimos.manipulation.planning.planning_identifiers import ( + local_joint_name_from_resolved, + make_planning_group_id, + make_resolved_joint_name, + make_resolved_joint_names, + parse_planning_group_id, ) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import ObstacleType @@ -356,10 +356,12 @@ def list_planning_groups(self) -> tuple[PlanningGroupDescriptor, ...]: for group in config.planning_groups: descriptors.append( PlanningGroupDescriptor( - id=to_planning_group_id(config.name, group.name), + id=make_planning_group_id(config.name, group.name), robot_name=config.name, group_name=group.name, - joint_names=tuple(to_resolved_joint_names(config.name, group.joint_names)), + joint_names=tuple( + make_resolved_joint_names(config.name, group.joint_names) + ), local_joint_names=group.joint_names, base_link=group.base_link, tip_link=group.tip_link, @@ -376,7 +378,7 @@ def resolve_planning_groups( seen_joints: dict[str, PlanningGroupID] = {} for group_id in group_ids: - robot_name, group_name = split_planning_group_id(group_id) + robot_name, group_name = parse_planning_group_id(group_id) robot_data = self._get_robot_data_by_name(robot_name) group = next( ( @@ -390,7 +392,7 @@ def resolve_planning_groups( raise KeyError(f"Unknown planning group ID: {group_id}") resolved_joint_names = tuple( - to_resolved_joint_name(robot_name, local_name) for local_name in group.joint_names + make_resolved_joint_name(robot_name, local_name) for local_name in group.joint_names ) for joint_name in resolved_joint_names: previous_group_id = seen_joints.get(joint_name) @@ -956,7 +958,7 @@ def _positions_for_robot_state( def _state_name_to_local(self, config: RobotModelConfig, joint_name: str) -> str: try: - return strip_resolved_joint_name(config.name, joint_name) + return local_joint_name_from_resolved(config.name, joint_name) except ValueError: return config.get_urdf_joint_name(joint_name) @@ -979,7 +981,7 @@ def get_joint_state(self, ctx: Context, robot_id: WorldRobotID) -> JointState: positions = [float(full_positions[idx]) for idx in robot_data.joint_indices] return JointState( - name=to_resolved_joint_names(robot_data.config.name, robot_data.config.joint_names), + name=make_resolved_joint_names(robot_data.config.name, robot_data.config.joint_names), position=positions, ) From 0a403ded624a417732e6e28976232b2dd66100c8 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 17 Jun 2026 18:55:53 -0700 Subject: [PATCH 036/110] feat: multi-group rrt --- .../planning/planners/rrt_planner.py | 407 ++++++++++++++---- .../planners/test_rrt_planner_selection.py | 51 +++ 2 files changed, 386 insertions(+), 72 deletions(-) diff --git a/dimos/manipulation/planning/planners/rrt_planner.py b/dimos/manipulation/planning/planners/rrt_planner.py index 9e41a2e9ff..04e5b8528a 100644 --- a/dimos/manipulation/planning/planners/rrt_planner.py +++ b/dimos/manipulation/planning/planners/rrt_planner.py @@ -222,64 +222,197 @@ def _plan_multi_robot_selected_joint_path( goal: JointState, timeout: float, ) -> PlanningResult: - """Plan each selected robot independently and synchronize waypoints. - - This supports coordinated joint targets across multiple robots when the - selected groups cover each affected robot's full controllable joint set. - Cross-robot collision coupling is not optimized by this backend; each - per-robot plan is still checked by the world backend for that robot. - """ + """Plan over one coupled configuration vector for all selected robots.""" start_time = time.time() - groups_by_robot: dict[WorldRobotID, list[ResolvedPlanningGroup]] = {} - robot_order: list[WorldRobotID] = [] - for group in resolved_groups: - if group.robot_id not in groups_by_robot: - groups_by_robot[group.robot_id] = [] - robot_order.append(group.robot_id) - groups_by_robot[group.robot_id].append(group) - - paths_by_robot: dict[WorldRobotID, JointPath] = {} - total_iterations = 0 - for robot_id in robot_order: - robot_config = world.get_robot_config(robot_id) - robot_joint_names = [ - joint_name - for group in groups_by_robot[robot_id] - for joint_name in group.joint_names - ] - full_resolved_joint_names = [ - f"{robot_config.name}/{joint_name}" for joint_name in robot_config.joint_names - ] - if robot_joint_names != full_resolved_joint_names: + + if not world.is_finalized: + return _create_failure_result( + PlanningStatus.NO_SOLUTION, + "World must be finalized before planning", + ) + + selected_joint_names = [joint for group in resolved_groups for joint in group.joint_names] + q_start = np.array( + _order_joint_state(start, selected_joint_names).position, dtype=np.float64 + ) + q_goal = np.array(_order_joint_state(goal, selected_joint_names).position, dtype=np.float64) + + try: + robot_order, robot_joint_names = _validate_full_robot_groups(world, resolved_groups) + except KeyError as exc: + return _create_failure_result(PlanningStatus.NO_SOLUTION, str(exc)) + if not robot_order: + return _create_failure_result( + PlanningStatus.INVALID_GOAL, "No planning groups selected" + ) + + unsupported = _validate_selected_groups_cover_full_robots( + world, robot_order, robot_joint_names + ) + if unsupported is not None: + return unsupported + + lower, upper = _combined_joint_limits(world, robot_order) + + if not _coupled_config_collision_free( + world, robot_order, robot_joint_names, selected_joint_names, q_start + ): + return _create_failure_result( + PlanningStatus.COLLISION_AT_START, + "Start configuration is in collision", + ) + if not _coupled_config_collision_free( + world, robot_order, robot_joint_names, selected_joint_names, q_goal + ): + return _create_failure_result( + PlanningStatus.COLLISION_AT_GOAL, + "Goal configuration is in collision", + ) + + if np.any(q_start < lower) or np.any(q_start > upper): + return _create_failure_result( + PlanningStatus.INVALID_START, + "Start configuration is outside joint limits", + ) + if np.any(q_goal < lower) or np.any(q_goal > upper): + return _create_failure_result( + PlanningStatus.INVALID_GOAL, + "Goal configuration is outside joint limits", + ) + + if _coupled_edge_collision_free( + world, + robot_order, + robot_joint_names, + selected_joint_names, + q_start, + q_goal, + self._collision_step_size, + ): + return _create_success_result( + [start, goal], + time.time() - start_time, + 0, + ) + + start_tree = [TreeNode(config=q_start.copy())] + goal_tree = [TreeNode(config=q_goal.copy())] + trees_swapped = False + + max_iterations = 5000 + for iteration in range(max_iterations): + if time.time() - start_time > timeout: return _create_failure_result( - PlanningStatus.UNSUPPORTED, - "RRTConnectPlanner currently requires selected groups to cover " - "each affected robot's controllable joint set exactly", + PlanningStatus.TIMEOUT, + f"Timeout after {iteration} iterations", + time.time() - start_time, + iteration, ) - remaining_timeout = max(timeout - (time.time() - start_time), 0.001) - result = self.plan_joint_path( - world=world, - robot_id=robot_id, - start=_order_joint_state(start, robot_joint_names), - goal=_order_joint_state(goal, robot_joint_names), - timeout=remaining_timeout, + sample = np.random.uniform(lower, upper) + extended = self._extend_coupled_tree( + world, + robot_order, + robot_joint_names, + start_tree, + sample, + self._step_size, + selected_joint_names, ) - total_iterations += result.iterations - if not result.is_success(): - return result - paths_by_robot[robot_id] = result.path - - combined_path = _synchronize_robot_paths(robot_order, paths_by_robot) - return PlanningResult( - status=PlanningStatus.SUCCESS, - path=combined_path, - planning_time=time.time() - start_time, - path_length=compute_path_length(combined_path), - iterations=total_iterations, - message="Multi-robot plan composed from independently planned robot paths", + + if extended is not None: + connected = self._connect_coupled_tree( + world, + robot_order, + robot_joint_names, + goal_tree, + extended.config, + self._connect_step_size, + selected_joint_names, + ) + if connected is not None: + path = self._extract_path(extended, connected, selected_joint_names) + if trees_swapped: + path = list(reversed(path)) + path = _simplify_coupled_path( + world, + robot_order, + robot_joint_names, + path, + self._collision_step_size, + ) + return _create_success_result(path, time.time() - start_time, iteration + 1) + + start_tree, goal_tree = goal_tree, start_tree + trees_swapped = not trees_swapped + + return _create_failure_result( + PlanningStatus.NO_SOLUTION, + f"No path found after {max_iterations} iterations", + time.time() - start_time, + max_iterations, ) + def _extend_coupled_tree( + self, + world: WorldSpec, + robot_order: list[WorldRobotID], + robot_joint_names: dict[WorldRobotID, list[str]], + tree: list[TreeNode], + target: NDArray[np.float64], + step_size: float, + selected_joint_names: list[str], + ) -> TreeNode | None: + """Extend a tree in the coupled selected-joint configuration space.""" + nearest = min(tree, key=lambda node: float(np.linalg.norm(node.config - target))) + diff = target - nearest.config + dist = float(np.linalg.norm(diff)) + if dist <= step_size: + new_config = target.copy() + else: + new_config = nearest.config + step_size * (diff / dist) + + if _coupled_edge_collision_free( + world, + robot_order, + robot_joint_names, + selected_joint_names, + nearest.config, + new_config, + self._collision_step_size, + ): + new_node = TreeNode(config=new_config, parent=nearest) + nearest.children.append(new_node) + tree.append(new_node) + return new_node + return None + + def _connect_coupled_tree( + self, + world: WorldSpec, + robot_order: list[WorldRobotID], + robot_joint_names: dict[WorldRobotID, list[str]], + tree: list[TreeNode], + target: NDArray[np.float64], + step_size: float, + selected_joint_names: list[str], + ) -> TreeNode | None: + """Try to connect a coupled tree to a target configuration.""" + while True: + result = self._extend_coupled_tree( + world, + robot_order, + robot_joint_names, + tree, + target, + step_size, + selected_joint_names, + ) + if result is None: + return None + if float(np.linalg.norm(result.config - target)) < self._goal_tolerance: + return result + def _validate_inputs( self, world: WorldSpec, @@ -479,28 +612,158 @@ def _create_failure_result( ) -def _synchronize_robot_paths( - robot_order: list[WorldRobotID], paths_by_robot: dict[WorldRobotID, JointPath] -) -> JointPath: - max_waypoints = max(len(path) for path in paths_by_robot.values()) - if max_waypoints == 0: - return [] - - combined: JointPath = [] - for waypoint_index in range(max_waypoints): - names: list[str] = [] - positions: list[float] = [] +def _validate_full_robot_groups( + world: WorldSpec, + resolved_groups: tuple[ResolvedPlanningGroup, ...], +) -> tuple[list[WorldRobotID], dict[WorldRobotID, list[str]]]: + robot_order: list[WorldRobotID] = [] + robot_joint_names: dict[WorldRobotID, list[str]] = {} + known_robot_ids = set(world.get_robot_ids()) + for group in resolved_groups: + if group.robot_id not in known_robot_ids: + raise KeyError(f"Robot '{group.robot_id}' not found") + if group.robot_id not in robot_joint_names: + robot_joint_names[group.robot_id] = [] + robot_order.append(group.robot_id) + robot_joint_names[group.robot_id].extend(group.joint_names) + return robot_order, robot_joint_names + + +def _validate_selected_groups_cover_full_robots( + world: WorldSpec, + robot_order: list[WorldRobotID], + robot_joint_names: dict[WorldRobotID, list[str]], +) -> PlanningResult | None: + for robot_id in robot_order: + robot_config = world.get_robot_config(robot_id) + full_resolved_joint_names = [ + f"{robot_config.name}/{joint_name}" for joint_name in robot_config.joint_names + ] + if robot_joint_names[robot_id] != full_resolved_joint_names: + return _create_failure_result( + PlanningStatus.UNSUPPORTED, + "RRTConnectPlanner currently requires selected groups to cover " + "each affected robot's controllable joint set exactly", + ) + return None + + +def _combined_joint_limits( + world: WorldSpec, + robot_order: list[WorldRobotID], +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + lower_parts: list[NDArray[np.float64]] = [] + upper_parts: list[NDArray[np.float64]] = [] + for robot_id in robot_order: + lower, upper = world.get_joint_limits(robot_id) + lower_parts.append(lower) + upper_parts.append(upper) + return np.concatenate(lower_parts), np.concatenate(upper_parts) + + +def _robot_joint_state_from_combined( + combined_joint_names: list[str], + combined_positions: NDArray[np.float64], + robot_joint_names: list[str], +) -> JointState: + position_by_name = dict(zip(combined_joint_names, combined_positions.tolist(), strict=True)) + return JointState( + name=robot_joint_names, + position=[position_by_name[name] for name in robot_joint_names], + ) + + +def _coupled_config_collision_free( + world: WorldSpec, + robot_order: list[WorldRobotID], + robot_joint_names: dict[WorldRobotID, list[str]], + selected_joint_names: list[str], + q: NDArray[np.float64], +) -> bool: + with world.scratch_context() as ctx: for robot_id in robot_order: - path = paths_by_robot[robot_id] - if max_waypoints == 1 or len(path) == 1: - source_index = 0 - else: - source_index = round(waypoint_index * (len(path) - 1) / (max_waypoints - 1)) - waypoint = path[source_index] - names.extend(waypoint.name) - positions.extend(waypoint.position) - combined.append(JointState(name=names, position=positions)) - return combined + world.set_joint_state( + ctx, + robot_id, + _robot_joint_state_from_combined( + selected_joint_names, + q, + robot_joint_names[robot_id], + ), + ) + return all(world.is_collision_free(ctx, robot_id) for robot_id in robot_order) + + +def _coupled_edge_collision_free( + world: WorldSpec, + robot_order: list[WorldRobotID], + robot_joint_names: dict[WorldRobotID, list[str]], + selected_joint_names: list[str], + q_start: NDArray[np.float64], + q_end: NDArray[np.float64], + step_size: float, +) -> bool: + dist = float(np.linalg.norm(q_end - q_start)) + if dist < 1e-8: + return _coupled_config_collision_free( + world, + robot_order, + robot_joint_names, + selected_joint_names, + q_start, + ) + + n_steps = max(2, int(np.ceil(dist / step_size)) + 1) + with world.scratch_context() as ctx: + for i in range(n_steps): + t = i / (n_steps - 1) + q = q_start + t * (q_end - q_start) + for robot_id in robot_order: + world.set_joint_state( + ctx, + robot_id, + _robot_joint_state_from_combined( + selected_joint_names, + q, + robot_joint_names[robot_id], + ), + ) + if not all(world.is_collision_free(ctx, robot_id) for robot_id in robot_order): + return False + return True + + +def _simplify_coupled_path( + world: WorldSpec, + robot_order: list[WorldRobotID], + robot_joint_names: dict[WorldRobotID, list[str]], + path: JointPath, + collision_step_size: float, + max_iterations: int = 100, +) -> JointPath: + if len(path) <= 2: + return path + + simplified = list(path) + selected_joint_names = list(path[0].name) + for _ in range(max_iterations): + if len(simplified) <= 2: + break + i = np.random.randint(0, len(simplified) - 2) + j = np.random.randint(i + 2, len(simplified)) + q_start = np.array(simplified[i].position, dtype=np.float64) + q_end = np.array(simplified[j].position, dtype=np.float64) + if _coupled_edge_collision_free( + world, + robot_order, + robot_joint_names, + selected_joint_names, + q_start, + q_end, + collision_step_size, + ): + simplified = simplified[: i + 1] + simplified[j:] + return simplified def _validate_exact_joint_keys( diff --git a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py index 5d11e047d6..52ca068919 100644 --- a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py +++ b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py @@ -16,6 +16,8 @@ from __future__ import annotations +from collections.abc import Callable +from contextlib import nullcontext from pathlib import Path from typing import cast @@ -73,9 +75,12 @@ def __init__( self, groups: dict[str, ResolvedPlanningGroup], robot_configs: dict[str, RobotModelConfig], + coupled_collision_predicate: Callable[[dict[str, JointState]], bool] | None = None, ) -> None: self._groups = groups self._robot_configs = robot_configs + self._coupled_collision_predicate = coupled_collision_predicate + self.coupled_collision_checks = 0 def resolve_planning_groups( self, group_ids: tuple[str, ...] @@ -104,6 +109,20 @@ def check_edge_collision_free( ) -> bool: return True + def scratch_context(self) -> nullcontext[dict[str, JointState]]: + return nullcontext({}) + + def set_joint_state( + self, ctx: dict[str, JointState], robot_id: str, joint_state: JointState + ) -> None: + ctx[robot_id] = joint_state + + def is_collision_free(self, ctx: dict[str, JointState], robot_id: str) -> bool: + self.coupled_collision_checks += 1 + if self._coupled_collision_predicate is None: + return True + return self._coupled_collision_predicate(ctx) + def test_plan_selected_joint_path_rejects_missing_and_extra_start_names() -> None: world = _SelectionWorld( @@ -165,6 +184,38 @@ def test_plan_selected_joint_path_plans_cross_robot_full_group_selection() -> No assert len(result.path) == 2 assert result.path[0].name == ["left/joint1", "right/joint1"] assert result.path[-1].position == [0.1, -0.1] + assert world.coupled_collision_checks > 0 + + +def test_plan_selected_joint_path_rejects_cross_robot_coupled_goal_collision() -> None: + def coupled_free(ctx: dict[str, JointState]) -> bool: + if {"left_robot", "right_robot"} - set(ctx): + return True + left = ctx["left_robot"].position[0] + right = ctx["right_robot"].position[0] + return not (left > 0.04 and right > 0.04) + + world = _SelectionWorld( + groups={ + "left/arm": _group("left/arm", "left_robot", "left", ("left/joint1",)), + "right/arm": _group("right/arm", "right_robot", "right", ("right/joint1",)), + }, + robot_configs={ + "left_robot": _robot_config("left", ["joint1"]), + "right_robot": _robot_config("right", ["joint1"]), + }, + coupled_collision_predicate=coupled_free, + ) + + result = RRTConnectPlanner().plan_selected_joint_path( + cast("WorldSpec", world), + ["left/arm", "right/arm"], + start=_joint_state(["left/joint1", "right/joint1"], [0.0, 0.0]), + goal=_joint_state(["left/joint1", "right/joint1"], [0.1, 0.1]), + ) + + assert result.status == PlanningStatus.COLLISION_AT_GOAL + assert world.coupled_collision_checks > 0 def test_plan_selected_joint_path_rejects_single_robot_subset_selection() -> None: From 6b36a7023d3b5b69cffa9cd244763cfbee3e9790 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 17 Jun 2026 22:50:05 -0700 Subject: [PATCH 037/110] fix: address remaining planning group review --- dimos/manipulation/manipulation_module.py | 74 ++++++------------- .../planning/planning_group_utils.py | 59 +++++++++++++++ .../manipulation/planning/planning_groups.py | 6 +- dimos/manipulation/planning/spec/config.py | 15 ++-- dimos/manipulation/planning/spec/models.py | 18 ++++- dimos/manipulation/planning/spec/protocols.py | 24 +++--- .../planning/world/drake_world.py | 20 ++--- dimos/robot/config.py | 7 +- .../manipulation/adding_a_custom_arm.md | 10 +-- .../manipulation/planning_groups.md | 7 +- .../changes/add-planning-groups/design.md | 2 +- openspec/changes/add-planning-groups/tasks.md | 4 +- 12 files changed, 147 insertions(+), 99 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 2ceccb7feb..535e7ff3f3 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -47,6 +47,12 @@ kinematics_config_from_name, ) from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor +from dimos.manipulation.planning.planning_group_utils import ( + normalize_joint_target_for_group, + planning_group_id_from_selector, + primary_pose_planning_group_id_for_robot, + single_planning_group_id_for_robot, +) from dimos.manipulation.planning.planning_identifiers import make_resolved_joint_name from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType @@ -333,7 +339,7 @@ def _tf_publish_loop(self) -> None: transforms: list[Transform] = [] for robot_id, config, _ in self._robots.values(): # Publish world → primary planning-group target frame. - # Fall back to deprecated robot-scoped EE only for legacy configs. + # Fall back to robot-scoped EE only for compatibility configs. target_frame = config.end_effector_link pose_group_id = self._primary_pose_group_id_for_robot(config.name) if pose_group_id is not None: @@ -472,27 +478,20 @@ def _fail(self, msg: str) -> bool: def _default_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: """Return wrapper-level default group for legacy single-group RPCs.""" assert self._world_monitor is not None - group_ids = [ - group.id - for group in self._world_monitor.world.list_planning_groups() - if group.robot_name == robot_name - ] - if len(group_ids) != 1: - logger.error( - "Robot '%s' has %d planning groups; select a planning group explicitly", - robot_name, - len(group_ids), + try: + return single_planning_group_id_for_robot( + self._world_monitor.world.list_planning_groups(), robot_name ) + except ValueError as exc: + logger.error(str(exc)) return None - return group_ids[0] def _primary_pose_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: """Return the first pose-targetable group for robot-scoped compatibility paths.""" assert self._world_monitor is not None - for group in self._world_monitor.world.list_planning_groups(): - if group.robot_name == robot_name and group.has_pose_target: - return group.id - return None + return primary_pose_planning_group_id_for_robot( + self._world_monitor.world.list_planning_groups(), robot_name + ) def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> JointState | None: """Collect current state for exactly the selected resolved joints.""" @@ -528,43 +527,17 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint return JointState(name=names, position=positions) - def _normalize_group_selector( - self, selector: PlanningGroupID | PlanningGroupDescriptor - ) -> PlanningGroupID: - """Normalize a planning group selector to its stable public ID.""" - if isinstance(selector, PlanningGroupDescriptor): - return selector.id - return selector - def _normalize_joint_target( self, group_id: PlanningGroupID, target: JointState ) -> JointState | None: """Normalize a group joint target to resolved joint names in group order.""" assert self._world_monitor is not None group = self._world_monitor.world.resolve_planning_groups((group_id,))[0] - if not target.name: - if len(target.position) != len(group.joint_names): - logger.error("Target for '%s' has wrong joint count", group_id) - return None - return JointState(name=list(group.joint_names), position=list(target.position)) - - positions_by_name = dict(zip(target.name, target.position, strict=False)) - resolved_positions: list[float] = [] - for resolved_name, local_name in zip( - group.joint_names, group.local_joint_names, strict=False - ): - if resolved_name in positions_by_name: - resolved_positions.append(positions_by_name[resolved_name]) - elif local_name in positions_by_name: - resolved_positions.append(positions_by_name[local_name]) - else: - logger.error("Target for '%s' is missing joint '%s'", group_id, resolved_name) - return None - extra = set(target.name) - set(group.joint_names) - set(group.local_joint_names) - if extra: - logger.error("Target for '%s' has extra joints: %s", group_id, sorted(extra)) + try: + return normalize_joint_target_for_group(group, target) + except ValueError as exc: + logger.error(str(exc)) return None - return JointState(name=list(group.joint_names), position=resolved_positions) def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotName) -> JointPath: """Project combined plan path to one robot in configured local joint order. @@ -848,14 +821,14 @@ def plan_to_poses( self._state = ManipulationState.PLANNING stamped_targets = { - self._normalize_group_selector(group): PoseStamped( + planning_group_id_from_selector(group): PoseStamped( frame_id="world", position=pose.position, orientation=pose.orientation, ) for group, pose in pose_targets.items() } - auxiliary_ids = tuple(self._normalize_group_selector(group) for group in auxiliary_groups) + auxiliary_ids = tuple(planning_group_id_from_selector(group) for group in auxiliary_groups) group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_ids))) try: @@ -914,7 +887,7 @@ def plan_to_joint_targets( return False self._state = ManipulationState.PLANNING - group_ids = tuple(self._normalize_group_selector(group) for group in joint_targets) + group_ids = tuple(planning_group_id_from_selector(group) for group in joint_targets) try: start = self._selected_joint_state(group_ids) except Exception as exc: @@ -925,7 +898,7 @@ def plan_to_joint_targets( goal_names: list[str] = [] goal_positions: list[float] = [] for group, target in joint_targets.items(): - group_id = self._normalize_group_selector(group) + group_id = planning_group_id_from_selector(group) normalized = self._normalize_joint_target(group_id, target) if normalized is None: return self._fail(f"Invalid joint target for '{group_id}'") @@ -1150,7 +1123,6 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] "planning_groups": planning_groups, "end_effector_link": config.end_effector_link, "base_link": config.base_link, - "deprecated_fields": ["end_effector_link", "base_link"], "max_velocity": config.max_velocity, "max_acceleration": config.max_acceleration, "has_joint_name_mapping": bool(config.joint_name_mapping), diff --git a/dimos/manipulation/planning/planning_group_utils.py b/dimos/manipulation/planning/planning_group_utils.py index 2b2b42ad81..a6eb161b2a 100644 --- a/dimos/manipulation/planning/planning_group_utils.py +++ b/dimos/manipulation/planning/planning_group_utils.py @@ -21,6 +21,8 @@ PlanningGroupDescriptor, PlanningGroupID, ResolvedJointName, + ResolvedPlanningGroup, + RobotName, ) from dimos.msgs.sensor_msgs.JointState import JointState @@ -34,6 +36,31 @@ def planning_group_id_from_selector( return selector +def single_planning_group_id_for_robot( + groups: Sequence[PlanningGroupDescriptor], + robot_name: RobotName, +) -> PlanningGroupID: + """Return a robot's only planning group ID, or raise if ambiguous.""" + group_ids = [group.id for group in groups if group.robot_name == robot_name] + if len(group_ids) != 1: + raise ValueError( + f"Robot '{robot_name}' has {len(group_ids)} planning groups; " + "select a planning group explicitly" + ) + return group_ids[0] + + +def primary_pose_planning_group_id_for_robot( + groups: Sequence[PlanningGroupDescriptor], + robot_name: RobotName, +) -> PlanningGroupID | None: + """Return the first pose-targetable group ID for compatibility paths.""" + for group in groups: + if group.robot_name == robot_name and group.has_pose_target: + return group.id + return None + + def matching_resolved_joint_name( positions_by_name: Mapping[str, float], local_joint_name: LocalModelJointName ) -> ResolvedJointName | None: @@ -76,3 +103,35 @@ def filter_joint_state_to_selected_joints( raise ValueError(f"IK result is missing selected joints: {missing}") return JointState({"name": list(resolved_joint_names), "position": selected_positions}) + + +def normalize_joint_target_for_group( + group: ResolvedPlanningGroup, + target: JointState, +) -> JointState: + """Normalize a group joint target to resolved joint names in group order.""" + if not target.name: + if len(target.position) != len(group.joint_names): + raise ValueError( + f"Target for '{group.id}' has {len(target.position)} positions, " + f"expected {len(group.joint_names)}" + ) + return JointState(name=list(group.joint_names), position=list(target.position)) + + positions_by_name = dict(zip(target.name, target.position, strict=False)) + resolved_positions: list[float] = [] + missing: list[str] = [] + for resolved_name, local_name in zip(group.joint_names, group.local_joint_names, strict=False): + if resolved_name in positions_by_name: + resolved_positions.append(positions_by_name[resolved_name]) + elif local_name in positions_by_name: + resolved_positions.append(positions_by_name[local_name]) + else: + missing.append(resolved_name) + if missing: + raise ValueError(f"Target for '{group.id}' is missing joints: {missing}") + + extra = set(target.name) - set(group.joint_names) - set(group.local_joint_names) + if extra: + raise ValueError(f"Target for '{group.id}' has extra joints: {sorted(extra)}") + return JointState(name=list(group.joint_names), position=resolved_positions) diff --git a/dimos/manipulation/planning/planning_groups.py b/dimos/manipulation/planning/planning_groups.py index ed082f42f0..2b8c362512 100644 --- a/dimos/manipulation/planning/planning_groups.py +++ b/dimos/manipulation/planning/planning_groups.py @@ -80,12 +80,14 @@ def parse_srdf_planning_groups( model: ModelDescription, controllable_joint_names: list[str], ) -> list[PlanningGroupDefinition]: - """Parse supported SRDF planning group definitions. + """Extract supported SRDF planning group definitions. Supported forms are a single ```` child or an ordered list of ```` children. Other forms, including SRDF ```` metadata, are ignored for planning group - extraction. + extraction. This is intentionally a minimal SRDF group extractor rather + than a full SRDF parser; adopting a ROS/MoveIt parser such as srdfdom would + add substantial dependency overhead for this narrow subset. """ root = ET.parse(srdf_path).getroot() groups: list[PlanningGroupDefinition] = [] diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index b607fe6148..b4c98d2275 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -32,15 +32,16 @@ class RobotModelConfig(ModuleConfig): name: Human-readable robot name model_path: Path to robot model file (.urdf, .xacro, or .xml/MJCF) srdf_path: Optional path to SRDF file containing planning group definitions - base_pose: Deprecated planning placement transform retained for - compatibility. Prefer encoding placement in the robot model. + base_pose: Compatibility placement transform used by current Drake + world loading/welding. Prefer encoding new placement in the robot + model when possible. joint_names: Ordered list of controllable/coordinator joints in the local model namespace. This is not a planning group. - end_effector_link: Deprecated robot-scoped end-effector link retained - for compatibility. Pose targets should use planning group target - frames instead. - base_link: Deprecated robot-scoped base link retained for Drake weld - compatibility. Planning groups own chain base links. + end_effector_link: Compatibility robot-scoped end-effector link used by + legacy helpers. New pose-targeted planning should use planning + group target frames instead. + base_link: Compatibility robot-scoped base link used by current Drake + weld/placement behavior. Planning groups own chain base links. package_paths: Dict mapping package names to filesystem Paths joint_limits_lower: Lower joint limits (radians) joint_limits_upper: Upper joint limits (radians) diff --git a/dimos/manipulation/planning/spec/models.py b/dimos/manipulation/planning/spec/models.py index 729b5a7ac6..8bd91070ce 100644 --- a/dimos/manipulation/planning/spec/models.py +++ b/dimos/manipulation/planning/spec/models.py @@ -63,7 +63,9 @@ class PlanningGroupDefinition: """Model-level declaration of a planning group. Joint names are local model names. The definition is not bound to a world - robot ID and is safe to store on RobotModelConfig. + robot ID and is safe to store on RobotModelConfig. Definitions are parsed + from SRDF or conservative fallback generation before any robot instance is + added to a planning world. """ name: str @@ -80,7 +82,12 @@ def has_pose_target(self) -> bool: @dataclass(frozen=True) class PlanningGroupDescriptor: - """Read-only public snapshot for an available planning group.""" + """Read-only public snapshot for an available planning group. + + Descriptors are returned by query APIs. They expose stable public IDs and + resolved joint names for callers, but intentionally do not expose backend + runtime IDs or mutable world state. + """ id: PlanningGroupID robot_name: RobotName @@ -99,7 +106,12 @@ def has_pose_target(self) -> bool: @dataclass(frozen=True) class ResolvedPlanningGroup: - """Runtime/world-bound planning group data.""" + """Runtime/world-bound planning group data. + + Resolved groups are created from descriptors/IDs for a specific planning + world. They include the internal WorldRobotID and are the form consumed by + planners, IK backends, and group-scoped FK/Jacobian calls. + """ id: PlanningGroupID robot_id: WorldRobotID diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index ed20ce57e1..d36b5ab0c4 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -75,13 +75,23 @@ def get_robot_config(self, robot_id: WorldRobotID) -> RobotModelConfig: ... def list_planning_groups(self) -> tuple[PlanningGroupDescriptor, ...]: - """List available planning groups as immutable descriptor snapshots.""" + """List planning groups for robots currently added to this world. + + SRDF/fallback parsing creates model-level definitions before world + binding. This query returns world-level descriptor snapshots with stable + public IDs and resolved joint names. + """ ... def resolve_planning_groups( self, group_ids: list[PlanningGroupID] | tuple[PlanningGroupID, ...] ) -> tuple[ResolvedPlanningGroup, ...]: - """Resolve planning group IDs against current world robot data.""" + """Resolve group IDs against this world's runtime robot bindings. + + Resolution is world-bound: it looks up the robots added to this world, + attaches WorldRobotID, validates selected groups, and returns data that + backend planners/IK can use with world contexts. + """ ... def get_joint_limits( @@ -177,10 +187,7 @@ def get_group_jacobian(self, ctx: Any, group_id: PlanningGroupID) -> NDArray[np. ... def get_ee_pose(self, ctx: Any, robot_id: WorldRobotID) -> PoseStamped: - """Get robot-scoped end-effector pose. - - Deprecated: use get_group_pose() with an explicit planning group ID. - """ + """Get pose for a robot's unique pose-targetable planning group.""" ... def get_link_pose( @@ -190,10 +197,7 @@ def get_link_pose( ... def get_jacobian(self, ctx: Any, robot_id: WorldRobotID) -> NDArray[np.float64]: - """Get robot-scoped end-effector Jacobian (6 x n_joints). - - Deprecated: use get_group_jacobian() with an explicit planning group ID. - """ + """Get Jacobian for a robot's unique pose-targetable planning group.""" ... diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index d0fb4d580e..32d1d8fcf6 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -117,8 +117,8 @@ class _RobotData: config: RobotModelConfig model_instance: Any # ModelInstanceIndex joint_indices: list[int] # Indices into plant's position vector - ee_frame: Any | None # Deprecated robot-scoped end-effector frame - base_frame: Any # Deprecated robot-scoped base frame + ee_frame: Any | None # Compatibility robot-scoped end-effector frame + base_frame: Any # Compatibility robot-scoped base frame preview_model_instance: Any = None # ModelInstanceIndex for preview (yellow) robot preview_joint_indices: list[int] = field(default_factory=list) @@ -227,8 +227,9 @@ def add_robot(self, config: RobotModelConfig) -> WorldRobotID: """Add a robot to the world. Returns robot_id. Same model_path + base_pose reuses the model instance (e.g. two arms in one URDF). - base_pose/base_link/end_effector_link are deprecated compatibility fields; - planning should use planning-group base/tip links. + base_pose/base_link/end_effector_link remain compatibility fields for + placement and robot-scoped helpers; group-aware planning should use + planning group base/tip links. """ if self._finalized: raise RuntimeError("Cannot add robot after world is finalized") @@ -265,7 +266,7 @@ def add_robot(self, config: RobotModelConfig) -> WorldRobotID: return robot_id def _legacy_ee_frame(self, config: RobotModelConfig, model_instance: Any) -> Any | None: - """Resolve deprecated robot-scoped EE frame, if available.""" + """Resolve compatibility robot-scoped EE frame, if available.""" if config.end_effector_link is None: return None return self._plant.GetBodyByName(config.end_effector_link, model_instance).body_frame() @@ -1085,10 +1086,7 @@ def get_group_pose(self, ctx: Context, group_id: PlanningGroupID) -> PoseStamped return _pose_stamped_from_drake_transform(tip_pose) def get_ee_pose(self, ctx: Context, robot_id: WorldRobotID) -> PoseStamped: - """Get robot-scoped end-effector pose. - - Deprecated: use get_group_pose() with an explicit planning group ID. - """ + """Get pose for a robot's compatibility end-effector frame.""" if not self._finalized: raise RuntimeError("World must be finalized first") @@ -1131,9 +1129,7 @@ def get_link_pose( return result # type: ignore[no-any-return] def get_jacobian(self, ctx: Context, robot_id: WorldRobotID) -> NDArray[np.float64]: - """Get robot-scoped geometric Jacobian (6 x n_joints). - - Deprecated: use get_group_jacobian() with an explicit planning group ID. + """Get robot-scoped geometric Jacobian for the compatibility EE frame. Rows: [vx, vy, vz, wx, wy, wz] (linear, then angular) """ diff --git a/dimos/robot/config.py b/dimos/robot/config.py index e701099480..8dabdf24e1 100644 --- a/dimos/robot/config.py +++ b/dimos/robot/config.py @@ -55,7 +55,8 @@ class RobotConfig(BaseModel): # Required fields name: str model_path: Path | None = None - end_effector_link: str | None = None # Deprecated for planning; use planning group tip links. + # Compatibility robot-scoped target frame; new planning uses group tip links. + end_effector_link: str | None = None # Physical dimensions (meters) height_clearance: float | None = None # max height @@ -77,13 +78,13 @@ class RobotConfig(BaseModel): # Optional overrides (derived from model if not set) joint_names: list[str] | None = None base_link: str | None = ( - None # Deprecated planning override; derived from model root when absent. + None # Compatibility planning override; derived from model root when absent. ) home_joints: list[float] | None = None # Multi-robot / coordinator joint_prefix: str | None = None # defaults to "{name}_" - # Deprecated for planning placement. Prefer encoding placement in URDF/xacro/MJCF. + # Compatibility planning placement. Prefer encoding placement in URDF/xacro/MJCF. base_pose: list[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) # Planning diff --git a/docs/capabilities/manipulation/adding_a_custom_arm.md b/docs/capabilities/manipulation/adding_a_custom_arm.md index 3b373e4daf..a90dff7a80 100644 --- a/docs/capabilities/manipulation/adding_a_custom_arm.md +++ b/docs/capabilities/manipulation/adding_a_custom_arm.md @@ -561,8 +561,8 @@ def _make_yourarm_config( source="fallback", ) ], - base_pose=_make_base_pose(y=y_offset), # Deprecated; prefer model placement - base_link="base_link", # Deprecated robot-scoped compatibility + base_pose=_make_base_pose(y=y_offset), # Compatibility; prefer model placement + base_link="base_link", # Compatibility robot-scoped base package_paths={"yourarm_description": _YOURARM_PACKAGE_PATH}, xacro_args={}, # Xacro arguments if using .xacro files collision_exclusion_pairs=[], # Pairs of links that can touch (e.g., gripper fingers) @@ -603,9 +603,9 @@ yourarm_planner = manipulation_module( | `coordinator_task_name` | Must match the `TaskConfig.name` in your coordinator blueprint | | `collision_exclusion_pairs` | List of `(link_a, link_b)` tuples for links that may legitimately touch (e.g., gripper fingers) | -`base_link`, `base_pose`, and `end_effector_link` are deprecated planning-level -fields. New planning code should use SRDF/planning-group chain base/tip links -and encode robot placement in the model. See +`base_link`, `base_pose`, and `end_effector_link` are compatibility fields used +by current placement and robot-scoped helper paths. New planning code should use +SRDF/planning-group chain base/tip links and encode robot placement in the model. See [Planning Groups](/docs/capabilities/manipulation/planning_groups.md). ## Step 5: Register Blueprints diff --git a/docs/capabilities/manipulation/planning_groups.md b/docs/capabilities/manipulation/planning_groups.md index f5ab9cecd3..80dc9e6e0f 100644 --- a/docs/capabilities/manipulation/planning_groups.md +++ b/docs/capabilities/manipulation/planning_groups.md @@ -109,13 +109,14 @@ Multi-task dispatch is not atomic in this change: if one trajectory task accepts and a later task rejects, DimOS reports the rejection but does not roll back the accepted task. -## Deprecated planning config fields +## Compatibility planning config fields `RobotConfig.base_link`, `RobotConfig.base_pose`, `RobotModelConfig.base_link`, `RobotModelConfig.base_pose`, and `RobotModelConfig.end_effector_link` remain as compatibility fields for the -current Drake weld and deprecated robot-scoped FK/Jacobian APIs. New planning -logic should use model/SRDF structure and planning group base/tip links instead. +current Drake weld/placement behavior and robot-scoped compatibility helpers. +New planning logic should use model/SRDF structure and planning group base/tip +links instead. Robot placement should be encoded in URDF/xacro/MJCF. `joint_names` remains supported and should describe the controllable/coordinator joint set. diff --git a/openspec/changes/add-planning-groups/design.md b/openspec/changes/add-planning-groups/design.md index 3f24a3069b..cb1fa7e8fb 100644 --- a/openspec/changes/add-planning-groups/design.md +++ b/openspec/changes/add-planning-groups/design.md @@ -536,7 +536,7 @@ Simulation and replay should mirror hardware behavior because group resolution a - **SRDF scope:** Parse `` only; ignore ``. - **Unsupported SRDF:** Skip unsupported group forms with warnings. - **SRDF discovery:** Explicit path, then warning auto-discovery, then fallback, then error. -- **Config fields:** Add `srdf_path`; remove planning-level `base_link`, `end_effector_link`, and `base_pose` in the new design. +- **Config fields:** Add `srdf_path`; keep `base_link`, `end_effector_link`, and `base_pose` as explicit compatibility fields while new planning code uses planning-group base/tip links and model-owned placement. - **Robot placement:** Placement belongs in URDF/model, not separate planning config transforms. - **FK/Jacobian:** Replace robot-scoped end-effector APIs with group-scoped APIs. - **Cross-robot planning:** Interface allows it; backends may report unsupported. diff --git a/openspec/changes/add-planning-groups/tasks.md b/openspec/changes/add-planning-groups/tasks.md index b706372e11..e8523fc4bd 100644 --- a/openspec/changes/add-planning-groups/tasks.md +++ b/openspec/changes/add-planning-groups/tasks.md @@ -58,8 +58,8 @@ ## 6. Robot config migration and API cleanup -- [x] 6.1 Remove or deprecate planning-level `RobotConfig.base_link` and `RobotConfig.base_pose` usage. -- [x] 6.2 Remove or deprecate planning-level `RobotModelConfig.base_link`, `RobotModelConfig.base_pose`, and `RobotModelConfig.end_effector_link` usage. +- [x] 6.1 Reframe planning-level `RobotConfig.base_link` and `RobotConfig.base_pose` usage as active compatibility behavior. +- [x] 6.2 Reframe planning-level `RobotModelConfig.base_link`, `RobotModelConfig.base_pose`, and `RobotModelConfig.end_effector_link` usage as active compatibility behavior. - [x] 6.3 Keep and document `RobotConfig.joint_names` and `RobotModelConfig.joint_names` as controllable/coordinator joint sets, not planning groups. - [x] 6.4 Update existing robot catalog/config entries to use SRDF where needed or rely on fallback for unambiguous single-chain arms. - [x] 6.5 Update manipulation skills/wrappers to select planning groups explicitly or provide clear wrapper-level defaults. From 31731b1ac871d8ff0857ec5544ec360c72e6a4ab Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 17 Jun 2026 23:16:52 -0700 Subject: [PATCH 038/110] feat: dual xarm example --- dimos/manipulation/blueprints.py | 49 ++++++++++++++++++++++++++++++ dimos/robot/all_blueprints.py | 1 + dimos/robot/test_all_blueprints.py | 1 + 3 files changed, 51 insertions(+) diff --git a/dimos/manipulation/blueprints.py b/dimos/manipulation/blueprints.py index 14409bab7d..b8be3b5216 100644 --- a/dimos/manipulation/blueprints.py +++ b/dimos/manipulation/blueprints.py @@ -25,6 +25,10 @@ # 3. Interactive RPC client (plan, preview, execute from Python): dimos run xarm7-planner-coordinator python -i -m dimos.manipulation.planning.examples.manipulation_client + + # 4. Dual-arm xArm7 mock planner + coordinator demo: + dimos run dual-xarm7-planner-coordinator + python -i -m dimos.manipulation.planning.examples.manipulation_client """ import math @@ -121,6 +125,50 @@ ) +# Dual XArm7 mock planner + coordinator for bimanual planning demos. +# Usage: dimos run dual-xarm7-planner-coordinator +_left_xarm7_cfg = _catalog_xarm7( + name="left_arm", + adapter_type="mock", + add_gripper=False, + y_offset=0.5, +) +_right_xarm7_cfg = _catalog_xarm7( + name="right_arm", + adapter_type="mock", + add_gripper=False, + y_offset=-0.5, +) + +dual_xarm7_planner_coordinator = autoconnect( + ManipulationModule.blueprint( + robots=[ + _left_xarm7_cfg.to_robot_model_config(), + _right_xarm7_cfg.to_robot_model_config(), + ], + planning_timeout=10.0, + enable_viz=True, + ), + ControlCoordinator.blueprint( + tick_rate=100.0, + publish_joint_state=True, + joint_state_frame_id="coordinator", + hardware=[ + _left_xarm7_cfg.to_hardware_component(), + _right_xarm7_cfg.to_hardware_component(), + ], + tasks=[ + _left_xarm7_cfg.to_task_config(), + _right_xarm7_cfg.to_task_config(), + ], + ), +).transports( + { + ("joint_state", JointState): LCMTransport("/coordinator/joint_state", JointState), + } +) + + # XArm7 planner + LLM agent for testing base ManipulationModule skills # No perception — uses the base module's planning + gripper skills only. # Usage: dimos run coordinator-mock, then dimos run xarm7-planner-coordinator-agent @@ -339,6 +387,7 @@ __all__ = [ "dual_xarm6_planner", + "dual_xarm7_planner_coordinator", "xarm6_planner_only", "xarm7_planner_coordinator", "xarm7_planner_coordinator_agent", diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 24d90dd7fe..7d6dd776f6 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -58,6 +58,7 @@ "drone-agentic": "dimos.robot.drone.blueprints.agentic.drone_agentic:drone_agentic", "drone-basic": "dimos.robot.drone.blueprints.basic.drone_basic:drone_basic", "dual-xarm6-planner": "dimos.manipulation.blueprints:dual_xarm6_planner", + "dual-xarm7-planner-coordinator": "dimos.manipulation.blueprints:dual_xarm7_planner_coordinator", "keyboard-teleop-a750": "dimos.robot.manipulators.a750.blueprints:keyboard_teleop_a750", "keyboard-teleop-openarm": "dimos.robot.manipulators.openarm.blueprints:keyboard_teleop_openarm", "keyboard-teleop-openarm-mock": "dimos.robot.manipulators.openarm.blueprints:keyboard_teleop_openarm_mock", diff --git a/dimos/robot/test_all_blueprints.py b/dimos/robot/test_all_blueprints.py index cdf72e9b6b..ac900e67ea 100644 --- a/dimos/robot/test_all_blueprints.py +++ b/dimos/robot/test_all_blueprints.py @@ -50,6 +50,7 @@ "coordinator-xarm6", "coordinator-xarm7", "dual-xarm6-planner", + "dual-xarm7-planner-coordinator", "teleop-hosted-go2", "teleop-hosted-xarm7", "teleop-quest-dual", From fe3957fbb3a074f13a67d7ce5748087f1b671aa2 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 00:50:15 -0700 Subject: [PATCH 039/110] refactor: simplify manipulator joint naming --- CONTEXT.md | 34 ++++- dimos/manipulation/manipulation_module.py | 137 ++++++++++-------- dimos/manipulation/planning/README.md | 4 +- .../planning/kinematics/pink_ik.py | 10 +- .../planning/monitor/robot_state_monitor.py | 49 ++----- .../planning/monitor/world_monitor.py | 6 - .../planning/planners/rrt_planner.py | 25 ++-- .../planners/test_rrt_planner_selection.py | 1 + .../planning/planning_group_utils.py | 48 +++--- .../planning/planning_identifiers.py | 90 ++++++++---- dimos/manipulation/planning/spec/config.py | 32 ++-- dimos/manipulation/planning/spec/models.py | 10 +- dimos/manipulation/planning/spec/protocols.py | 2 +- .../planning/world/drake_world.py | 32 ++-- .../world/test_drake_world_planning_groups.py | 26 +--- .../manipulation/test_manipulation_module.py | 38 ++--- dimos/manipulation/test_manipulation_unit.py | 116 +++++---------- dimos/robot/catalog/openarm.py | 4 - dimos/robot/config.py | 47 ++---- dimos/robot/manipulators/xarm/blueprints.py | 4 +- .../manipulation/adding_a_custom_arm.md | 14 +- .../manipulation/planning_groups.md | 33 +++-- docs/capabilities/manipulation/readme.md | 2 +- 23 files changed, 344 insertions(+), 420 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 6597467a7f..a5af10041e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,6 +4,18 @@ Shared vocabulary for DimOS robotics concepts. These terms define domain languag ## Language +**Robot Name**: +A stable planning-domain identity for a concrete robot/model instance, used in public planning group and flat joint-name scoping. +_Avoid_: World robot ID, hardware ID, namespace + +**World Robot ID**: +An internal planning-world handle for a robot after it has been added to a backend world. +_Avoid_: Robot name, hardware ID, joint namespace + +**Hardware ID**: +A control-layer routing identity for a hardware component. For manipulator robots, it normally matches the robot name at the coordinator boundary. +_Avoid_: Robot name when discussing planning semantics, world robot ID + **Planning Group**: A named selectable serial kinematic chain of robot joints used as the unit of manipulation planning. A planning group is defined by its chain/joints, not by end-effector metadata. _Avoid_: Move group, movegroup @@ -17,7 +29,7 @@ Separate metadata used for pose-targeted operations. For a planning group define _Avoid_: Planning group definition **Resolved Planning Group**: -A planning group after model-level declarations have been bound to a concrete robot, namespace, and planning world. +A planning group after model-level declarations have been bound to a concrete robot name and planning world. _Avoid_: Planning group config, robot ID **Planning Group Selection**: @@ -33,7 +45,7 @@ A planning request over one or more selected planning groups that is solved as o _Avoid_: Batch planning, independent planning **Planning Group ID**: -An API-level identifier for a planning group, always namespaced as `{robot_name}/{group_name}`. +An API-level identifier for a planning group, always written as `{robot_name}/{group_name}`. `/` is reserved as the delimiter and is not part of either component. _Avoid_: Bare group name, robot ID **Planning Group Descriptor**: @@ -41,20 +53,28 @@ A read-only snapshot returned by query APIs that describes an available planning _Avoid_: Live planning group handle, resolved planning group **Joint State**: -A resolved-joint-name-keyed robot state that can represent any set of joints and is not inherently coupled to a robot, planning group, or planning group selection. +A joint-name-keyed robot state that can represent any set of joints and is not inherently coupled to a robot, planning group, planning group selection, or joint-name scope. At flat multi-robot or coordinator boundaries, joint names are required and are global joint names. Robot identity and local-vs-global meaning are provided by the API boundary or containing type, not by extra fields on the generic joint state. _Avoid_: Planning-group-scoped state **Robot Model Joint Names**: -The objective set of controllable joints exposed by a robot coordinator for state and command. This usually aligns with the model's actuated joints, but is not itself a planning group. +The ordered controllable joints of a robot model in the model's local namespace. This usually aligns with the model's actuated joints, but is not itself a planning group. _Avoid_: Implicit planning group **Local Model Joint Name**: A joint name as it appears inside a robot model or SRDF before the model is bound to a concrete robot in a planning world. _Avoid_: Runtime joint name, coordinator joint name -**Resolved Joint Name**: -A world-level joint name exposed above the model parsing layer, always namespaced as `{robot_name}/{local_joint_name}` so it is stable and unique within a planning world. -_Avoid_: Bare joint name, local joint name +**Robot-Scoped Joint State**: +A single-robot joint state whose robot identity is explicit outside the generic joint state. Robot-scoped APIs may accept unnamed ordered joint vectors in robot model joint order; when joint names are present, they are local model joint names because the robot identity is already explicit. +_Avoid_: Namespaced local joint state, prefixed joint state + +**Generated Plan**: +A flat planning result that may contain one or more robots. Joint states in a generated plan require names and use global joint names so the plan remains unambiguous across robot boundaries. +_Avoid_: Robot-scoped joint plan, local joint plan + +**Global Joint Name**: +A boundary-level joint name that mechanically combines a robot name and local model joint name as `{robot_name}/{local_joint_name}` so it is stable and unique in flat joint-state representations, even when the local model joint name is already descriptive. `/` is reserved as the delimiter and is not part of either component. +_Avoid_: Resolved joint name, coordinator joint name, bare joint name, local joint name **Robot Placement**: The placement of a robot model within the planning world. Robot placement belongs in the robot model description rather than in a separate planning configuration transform. diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 535e7ff3f3..588834996c 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -53,7 +53,12 @@ primary_pose_planning_group_id_for_robot, single_planning_group_id_for_robot, ) -from dimos.manipulation.planning.planning_identifiers import make_resolved_joint_name +from dimos.manipulation.planning.planning_identifiers import ( + assert_global_joint_names, + assert_local_joint_names, + make_global_joint_name, + make_global_joint_names, +) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType from dimos.manipulation.planning.spec.models import ( @@ -281,27 +286,33 @@ def _get_robot( def _on_joint_state(self, msg: JointState) -> None: """Callback when joint state received from driver. - Splits the aggregated JointState by robot using each robot's - coordinator joint names, then routes to the correct monitor. + Splits the aggregated global JointState by robot, then routes local + robot-scoped states to the correct monitor. """ try: if self._world_monitor is None: return + if not msg.name: + raise ValueError("Aggregate joint states must include global joint names") + assert_global_joint_names(msg.name) + # Build name → index map once for the whole message name_to_idx = {name: i for i, name in enumerate(msg.name)} for robot_name, (robot_id, config, _) in self._robots.items(): - coord_names = config.get_coordinator_joint_names() - indices = [name_to_idx.get(cn) for cn in coord_names] + global_names = make_global_joint_names(robot_name, config.joint_names) + indices = [name_to_idx.get(global_name) for global_name in global_names] if any(idx is None for idx in indices): missing = [ - cn for cn, idx in zip(coord_names, indices, strict=False) if idx is None + name + for name, idx in zip(global_names, indices, strict=False) + if idx is None ] logger.warning(f"Skipping '{robot_name}': missing joints {missing}") continue - # Build per-robot sub-message (coordinator namespace) + # Build per-robot sub-message (local model namespace) sub_positions = [msg.position[idx] for idx in indices] # type: ignore[index] sub_velocities = ( [msg.velocity[idx] for idx in indices] # type: ignore[index] @@ -309,7 +320,7 @@ def _on_joint_state(self, msg: JointState) -> None: else [] ) sub_msg = JointState( - name=list(coord_names), + name=list(config.joint_names), position=sub_positions, velocity=sub_velocities, ) @@ -494,7 +505,7 @@ def _primary_pose_group_id_for_robot(self, robot_name: RobotName) -> PlanningGro ) def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> JointState | None: - """Collect current state for exactly the selected resolved joints.""" + """Collect current state for exactly the selected global joints.""" assert self._world_monitor is not None resolved_groups = self._world_monitor.world.resolve_planning_groups(group_ids) names: list[str] = [] @@ -530,7 +541,7 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint def _normalize_joint_target( self, group_id: PlanningGroupID, target: JointState ) -> JointState | None: - """Normalize a group joint target to resolved joint names in group order.""" + """Normalize a group joint target to global joint names in group order.""" assert self._world_monitor is not None group = self._world_monitor.world.resolve_planning_groups((group_id,))[0] try: @@ -542,13 +553,13 @@ def _normalize_joint_target( def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotName) -> JointPath: """Project combined plan path to one robot in configured local joint order. - Generated plans only contain selected resolved joints. Trajectory tasks may + Generated plans only contain selected global joints. Trajectory tasks may still be configured for the robot's full controllable joint set, so non-selected joints are held at their current positions during projection. """ robot_id, config, _ = self._robots[robot_name] - resolved_joint_names = [ - make_resolved_joint_name(robot_name, joint) for joint in config.joint_names + global_joint_names = [ + make_global_joint_name(robot_name, joint) for joint in config.joint_names ] current_by_name: dict[str, float] = {} if self._world_monitor is not None: @@ -559,20 +570,20 @@ def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotNam for waypoint in plan.path: position_by_name = dict(zip(waypoint.name, waypoint.position, strict=False)) positions: list[float] = [] - for local_name, resolved_name in zip( - config.joint_names, resolved_joint_names, strict=False + for local_name, global_name in zip( + config.joint_names, global_joint_names, strict=False ): - if resolved_name in position_by_name: - positions.append(position_by_name[resolved_name]) - elif resolved_name in current_by_name: - positions.append(current_by_name[resolved_name]) + if global_name in position_by_name: + positions.append(position_by_name[global_name]) + elif global_name in current_by_name: + positions.append(current_by_name[global_name]) elif local_name in current_by_name: positions.append(current_by_name[local_name]) else: logger.error( "Cannot project plan for '%s': missing joint '%s'", robot_name, - resolved_name, + global_name, ) return [] projected.append( @@ -594,7 +605,7 @@ def _trajectory_for_robot_plan( _, config, traj_gen = self._robots[robot_name] trajectory = traj_gen.generate([list(state.position) for state in projected_path]) return JointTrajectory( - joint_names=list(config.joint_names), + joint_names=make_global_joint_names(robot_name, config.joint_names), points=trajectory.points, timestamp=trajectory.timestamp, ) @@ -1125,7 +1136,6 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] "base_link": config.base_link, "max_velocity": config.max_velocity, "max_acceleration": config.max_acceleration, - "has_joint_name_mapping": bool(config.joint_name_mapping), "coordinator_task_name": config.coordinator_task_name, "home_joints": config.home_joints, "pre_grasp_offset": config.pre_grasp_offset, @@ -1157,13 +1167,46 @@ def set_init_joints(self, joint_state: JointState, robot_name: RobotName | None robot = self._get_robot(robot_name) if robot is None: return False - self._init_joints[robot[0]] = joint_state + robot_name_resolved, _, config, _ = robot + try: + normalized = self._local_robot_joint_state(config, joint_state) + except ValueError as exc: + logger.error(str(exc)) + return False + self._init_joints[robot_name_resolved] = normalized logger.info( - f"Init joints set for '{robot[0]}': " - f"[{', '.join(f'{j:.3f}' for j in joint_state.position)}]" + f"Init joints set for '{robot_name_resolved}': " + f"[{', '.join(f'{j:.3f}' for j in normalized.position)}]" ) return True + def _local_robot_joint_state( + self, config: RobotModelConfig, joint_state: JointState + ) -> JointState: + """Normalize a robot-scoped joint state to local model joint order.""" + if not joint_state.name: + if len(joint_state.position) != len(config.joint_names): + raise ValueError( + f"JointState has {len(joint_state.position)} positions, " + f"expected {len(config.joint_names)} for robot '{config.name}'" + ) + return JointState(name=list(config.joint_names), position=list(joint_state.position)) + + assert_local_joint_names(joint_state.name) + positions_by_name = dict(zip(joint_state.name, joint_state.position, strict=False)) + missing = [name for name in config.joint_names if name not in positions_by_name] + if missing: + raise ValueError(f"JointState for robot '{config.name}' is missing joints: {missing}") + extra = set(joint_state.name) - set(config.joint_names) + if extra: + raise ValueError( + f"JointState for robot '{config.name}' has extra joints: {sorted(extra)}" + ) + return JointState( + name=list(config.joint_names), + position=[positions_by_name[name] for name in config.joint_names], + ) + @rpc def set_init_joints_to_current(self, robot_name: RobotName | None = None) -> bool: """Set init joints to the current joint positions. @@ -1201,36 +1244,6 @@ def _get_coordinator_client(self) -> RPCClient | None: self._coordinator_client = RPCClient(None, ControlCoordinator) return self._coordinator_client - def _translate_trajectory_to_coordinator( - self, - trajectory: JointTrajectory, - robot_config: RobotModelConfig, - ) -> JointTrajectory: - """Translate trajectory joint names from URDF to coordinator namespace. - - Args: - trajectory: Trajectory with URDF joint names - robot_config: Robot config with joint name mapping - - Returns: - Trajectory with coordinator joint names - """ - if not robot_config.joint_name_mapping: - return trajectory # No translation needed - - # Translate joint names - coordinator_names = [ - robot_config.get_coordinator_joint_name(j) for j in trajectory.joint_names - ] - - # Create new trajectory with translated names - # Note: duration is computed automatically from points in JointTrajectory.__init__ - return JointTrajectory( - joint_names=coordinator_names, - points=trajectory.points, - timestamp=trajectory.timestamp, - ) - @rpc def execute(self, robot_name: RobotName | None = None) -> bool: """Execute planned trajectory via ControlCoordinator.""" @@ -1252,15 +1265,12 @@ def execute(self, robot_name: RobotName | None = None) -> bool: logger.error("No coordinator client") return False - translated = self._translate_trajectory_to_coordinator(traj, config) logger.info( - f"Executing: task='{config.coordinator_task_name}', {len(translated.points)} pts, {translated.duration:.2f}s" + f"Executing: task='{config.coordinator_task_name}', {len(traj.points)} pts, {traj.duration:.2f}s" ) self._state = ManipulationState.EXECUTING - result = client.task_invoke( - config.coordinator_task_name, "execute", {"trajectory": translated} - ) + result = client.task_invoke(config.coordinator_task_name, "execute", {"trajectory": traj}) if result: logger.info("Trajectory accepted") self._state = ManipulationState.COMPLETED @@ -1307,15 +1317,14 @@ def execute_plan( self._state = ManipulationState.EXECUTING for name, config, trajectory in dispatches: self._planned_trajectories[name] = trajectory - translated = self._translate_trajectory_to_coordinator(trajectory, config) logger.info( "Executing: task='%s', %d pts, %.2fs", config.coordinator_task_name, - len(translated.points), - translated.duration, + len(trajectory.points), + trajectory.duration, ) result = client.task_invoke( - config.coordinator_task_name, "execute", {"trajectory": translated} + config.coordinator_task_name, "execute", {"trajectory": trajectory} ) if not result: return self._fail("Coordinator rejected trajectory") diff --git a/dimos/manipulation/planning/README.md b/dimos/manipulation/planning/README.md index 6e5a53eb57..a1712411d7 100644 --- a/dimos/manipulation/planning/README.md +++ b/dimos/manipulation/planning/README.md @@ -70,7 +70,6 @@ config = RobotModelConfig( joint_names=["joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7"], end_effector_link="link7", base_link="link_base", - joint_name_mapping={"arm_joint1": "joint1", ...}, # coordinator <-> URDF coordinator_task_name="traj_arm", ) @@ -93,12 +92,11 @@ module.execute() # Sends to coordinator | `name` | Robot identifier | | `model_path` | Path to URDF/XACRO file | | `base_pose` | PoseStamped for robot base in world frame | -| `joint_names` | Joint names in URDF | +| `joint_names` | Ordered controllable local model joint names | | `end_effector_link` | EE link name | | `base_link` | Base link name | | `max_velocity` | Max joint velocity (rad/s) | | `max_acceleration` | Max acceleration (rad/s²) | -| `joint_name_mapping` | Coordinator → URDF name mapping | | `coordinator_task_name` | Task name for execution RPC | | `package_paths` | ROS package paths for meshes | | `xacro_args` | Xacro arguments (e.g., `{"dof": "7"}`) | diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index 86a8d04241..e7a411ffdf 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -28,7 +28,7 @@ from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.planning_group_utils import ( filter_joint_state_to_selected_joints, - matching_resolved_joint_name, + matching_global_joint_name, planning_group_id_from_selector, ) from dimos.manipulation.planning.spec.config import RobotModelConfig @@ -186,7 +186,7 @@ def solve_pose_targets( check_collision: bool = True, max_attempts: int = 10, ) -> IKResult: - """Solve a planning-group pose target and return only selected resolved joints.""" + """Solve a planning-group pose target and return only selected global joints.""" if not pose_targets: return _failure(IKStatus.NO_SOLUTION, "At least one pose target is required") @@ -502,7 +502,7 @@ def _build_joint_mapping(model: Any, config: RobotModelConfig) -> _JointMapping: model_joint_names: list[str] = [] for dimos_name in config.joint_names: - model_joint_name = config.get_urdf_joint_name(dimos_name) + model_joint_name = dimos_name joint_id = _get_joint_id(model, model_joint_name) joint = model.joints[joint_id] nq = int(getattr(joint, "nq", 1)) @@ -562,9 +562,9 @@ def _seed_positions_for_mapping(seed: JointState, mapping: _JointMapping) -> NDA elif model_name in positions_by_name: values.append(float(positions_by_name[model_name])) elif ( - resolved_name := matching_resolved_joint_name(positions_by_name, dimos_name) + global_name := matching_global_joint_name(positions_by_name, dimos_name) ) is not None: - values.append(float(positions_by_name[resolved_name])) + values.append(float(positions_by_name[global_name])) else: raise ValueError(f"Seed is missing joint '{dimos_name}' (URDF name '{model_name}')") return np.array(values, dtype=np.float64) diff --git a/dimos/manipulation/planning/monitor/robot_state_monitor.py b/dimos/manipulation/planning/monitor/robot_state_monitor.py index 49c6b56366..39492a80ac 100644 --- a/dimos/manipulation/planning/monitor/robot_state_monitor.py +++ b/dimos/manipulation/planning/monitor/robot_state_monitor.py @@ -69,7 +69,6 @@ def __init__( lock: threading.RLock, robot_id: str, joint_names: list[str], - joint_name_mapping: dict[str, str] | None = None, timeout: float = 1.0, ) -> None: """Create a world state monitor. @@ -78,10 +77,7 @@ def __init__( world: WorldSpec instance to sync state to lock: Shared lock for thread-safe access robot_id: ID of the robot to monitor - joint_names: Ordered list of joint names for this robot (URDF names) - joint_name_mapping: Maps coordinator joint names to URDF joint names. - Example: {"left/joint1": "joint1"} means messages with "left/joint1" - will be mapped to URDF "joint1". If None, names must match exactly. + joint_names: Ordered list of local model joint names for this robot timeout: Timeout for waiting for initial state (seconds) """ self._world = world @@ -90,11 +86,6 @@ def __init__( self._joint_names = joint_names self._timeout = timeout - # Joint name mapping: coordinator name -> URDF name - self._joint_name_mapping = joint_name_mapping or {} - # Build reverse mapping: URDF name -> coordinator name - self._reverse_mapping = {v: k for k, v in self._joint_name_mapping.items()} - # Latest state self._latest_positions: NDArray[np.float64] | None = None self._latest_velocities: NDArray[np.float64] | None = None @@ -190,30 +181,19 @@ def on_joint_state(self, msg: JointState) -> None: def _extract_positions(self, msg: JointState) -> NDArray[np.float64] | None: """Extract positions for our joints from JointState message. - Handles joint name translation from coordinator namespace to URDF namespace. - If joint_name_mapping is set, message names are looked up via the reverse mapping. - Args: - msg: JointState message (may use coordinator joint names) + msg: Robot-scoped JointState message with local model joint names Returns: Array of joint positions or None if any joint is missing """ - # Build name->index map from message (coordinator names) name_to_idx = {name: i for i, name in enumerate(msg.name)} positions = [] - for urdf_joint_name in self._joint_names: - # Try direct match first (when no mapping or names already match) - if urdf_joint_name in name_to_idx: - idx = name_to_idx[urdf_joint_name] - else: - # Try reverse mapping: URDF name -> coordinator name -> msg index - orch_name = self._reverse_mapping.get(urdf_joint_name) - if orch_name is None or orch_name not in name_to_idx: - return None # Missing joint - idx = name_to_idx[orch_name] - + for local_joint_name in self._joint_names: + idx = name_to_idx.get(local_joint_name) + if idx is None: + return None if idx >= len(msg.position): return None # Position not available positions.append(msg.position[idx]) @@ -223,7 +203,7 @@ def _extract_positions(self, msg: JointState) -> NDArray[np.float64] | None: def _extract_velocities(self, msg: JointState) -> NDArray[np.float64] | None: """Extract velocities for our joints. - Uses same name translation as _extract_positions. + Uses the same local-name lookup as _extract_positions. """ if not msg.velocity or len(msg.velocity) == 0: return None @@ -231,17 +211,10 @@ def _extract_velocities(self, msg: JointState) -> NDArray[np.float64] | None: name_to_idx = {name: i for i, name in enumerate(msg.name)} velocities = [] - for urdf_joint_name in self._joint_names: - # Try direct match first - if urdf_joint_name in name_to_idx: - idx = name_to_idx[urdf_joint_name] - else: - # Try reverse mapping - orch_name = self._reverse_mapping.get(urdf_joint_name) - if orch_name is None or orch_name not in name_to_idx: - return None - idx = name_to_idx[orch_name] - + for local_joint_name in self._joint_names: + idx = name_to_idx.get(local_joint_name) + if idx is None: + return None if idx >= len(msg.velocity): return None velocities.append(msg.velocity[idx]) diff --git a/dimos/manipulation/planning/monitor/world_monitor.py b/dimos/manipulation/planning/monitor/world_monitor.py index 08dc5421e2..cb565b2b3f 100644 --- a/dimos/manipulation/planning/monitor/world_monitor.py +++ b/dimos/manipulation/planning/monitor/world_monitor.py @@ -123,7 +123,6 @@ def start_state_monitor( self, robot_id: WorldRobotID, joint_names: list[str] | None = None, - joint_name_mapping: dict[str, str] | None = None, ) -> None: """Start monitoring joint states. Uses config defaults if args are None.""" with self._lock: @@ -141,16 +140,11 @@ def start_state_monitor( else: joint_names = config.joint_names - # Get joint name mapping from config if not provided - if joint_name_mapping is None and config.joint_name_mapping: - joint_name_mapping = config.joint_name_mapping - monitor = RobotStateMonitor( world=self._world, lock=self._lock, robot_id=robot_id, joint_names=joint_names, - joint_name_mapping=joint_name_mapping, ) monitor.start() self._state_monitors[robot_id] = monitor diff --git a/dimos/manipulation/planning/planners/rrt_planner.py b/dimos/manipulation/planning/planners/rrt_planner.py index 04e5b8528a..641dc8d62c 100644 --- a/dimos/manipulation/planning/planners/rrt_planner.py +++ b/dimos/manipulation/planning/planners/rrt_planner.py @@ -26,6 +26,10 @@ import numpy as np +from dimos.manipulation.planning.planning_identifiers import ( + local_joint_name_from_global, + make_global_joint_names, +) from dimos.manipulation.planning.spec.enums import PlanningStatus from dimos.manipulation.planning.spec.models import ( JointPath, @@ -196,10 +200,10 @@ def plan_selected_joint_path( robot_id = next(iter(robot_ids)) robot_config = world.get_robot_config(robot_id) - full_resolved_joint_names = [ - f"{robot_config.name}/{joint_name}" for joint_name in robot_config.joint_names - ] - if selected_joint_names != full_resolved_joint_names: + full_global_joint_names = make_global_joint_names( + robot_config.name, robot_config.joint_names + ) + if selected_joint_names != full_global_joint_names: return _create_failure_result( PlanningStatus.UNSUPPORTED, "RRTConnectPlanner currently requires the selected groups to cover " @@ -636,10 +640,10 @@ def _validate_selected_groups_cover_full_robots( ) -> PlanningResult | None: for robot_id in robot_order: robot_config = world.get_robot_config(robot_id) - full_resolved_joint_names = [ - f"{robot_config.name}/{joint_name}" for joint_name in robot_config.joint_names - ] - if robot_joint_names[robot_id] != full_resolved_joint_names: + full_global_joint_names = make_global_joint_names( + robot_config.name, robot_config.joint_names + ) + if robot_joint_names[robot_id] != full_global_joint_names: return _create_failure_result( PlanningStatus.UNSUPPORTED, "RRTConnectPlanner currently requires selected groups to cover " @@ -664,11 +668,12 @@ def _combined_joint_limits( def _robot_joint_state_from_combined( combined_joint_names: list[str], combined_positions: NDArray[np.float64], + robot_name: str, robot_joint_names: list[str], ) -> JointState: position_by_name = dict(zip(combined_joint_names, combined_positions.tolist(), strict=True)) return JointState( - name=robot_joint_names, + name=[local_joint_name_from_global(robot_name, name) for name in robot_joint_names], position=[position_by_name[name] for name in robot_joint_names], ) @@ -688,6 +693,7 @@ def _coupled_config_collision_free( _robot_joint_state_from_combined( selected_joint_names, q, + world.get_robot_config(robot_id).name, robot_joint_names[robot_id], ), ) @@ -725,6 +731,7 @@ def _coupled_edge_collision_free( _robot_joint_state_from_combined( selected_joint_names, q, + world.get_robot_config(robot_id).name, robot_joint_names[robot_id], ), ) diff --git a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py index 52ca068919..5262916580 100644 --- a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py +++ b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py @@ -115,6 +115,7 @@ def scratch_context(self) -> nullcontext[dict[str, JointState]]: def set_joint_state( self, ctx: dict[str, JointState], robot_id: str, joint_state: JointState ) -> None: + assert joint_state.name == self._robot_configs[robot_id].joint_names ctx[robot_id] = joint_state def is_collision_free(self, ctx: dict[str, JointState], robot_id: str) -> bool: diff --git a/dimos/manipulation/planning/planning_group_utils.py b/dimos/manipulation/planning/planning_group_utils.py index a6eb161b2a..33cdf020b7 100644 --- a/dimos/manipulation/planning/planning_group_utils.py +++ b/dimos/manipulation/planning/planning_group_utils.py @@ -16,11 +16,12 @@ from collections.abc import Mapping, Sequence +from dimos.manipulation.planning.planning_identifiers import assert_local_joint_names from dimos.manipulation.planning.spec.models import ( + GlobalJointName, LocalModelJointName, PlanningGroupDescriptor, PlanningGroupID, - ResolvedJointName, ResolvedPlanningGroup, RobotName, ) @@ -61,10 +62,10 @@ def primary_pose_planning_group_id_for_robot( return None -def matching_resolved_joint_name( +def matching_global_joint_name( positions_by_name: Mapping[str, float], local_joint_name: LocalModelJointName -) -> ResolvedJointName | None: - """Find the unique resolved joint name ending with a local joint name.""" +) -> GlobalJointName | None: + """Find the unique global joint name ending with a local joint name.""" suffix = f"/{local_joint_name}" matches = [name for name in positions_by_name if name.endswith(suffix)] if len(matches) == 1: @@ -74,42 +75,42 @@ def matching_resolved_joint_name( def filter_joint_state_to_selected_joints( joint_state: JointState, - resolved_joint_names: Sequence[ResolvedJointName], + global_joint_names: Sequence[GlobalJointName], local_joint_names: Sequence[LocalModelJointName] = (), ) -> JointState: - """Project a joint state to selected resolved joints. + """Project a joint state to selected global joints. - Values are looked up by resolved name first. When ``local_joint_names`` is + Values are looked up by global name first. When ``local_joint_names`` is provided, each corresponding local name is used as a fallback. """ - if local_joint_names and len(resolved_joint_names) != len(local_joint_names): - raise ValueError("Resolved and local selected joint lists must have the same length") + if local_joint_names and len(global_joint_names) != len(local_joint_names): + raise ValueError("Global and local selected joint lists must have the same length") positions_by_name = dict(zip(joint_state.name, joint_state.position, strict=True)) selected_positions: list[float] = [] missing: list[str] = [] - for index, resolved_name in enumerate(resolved_joint_names): - if resolved_name in positions_by_name: - selected_positions.append(float(positions_by_name[resolved_name])) + for index, global_name in enumerate(global_joint_names): + if global_name in positions_by_name: + selected_positions.append(float(positions_by_name[global_name])) continue if local_joint_names: local_name = local_joint_names[index] if local_name in positions_by_name: selected_positions.append(float(positions_by_name[local_name])) continue - missing.append(resolved_name) + missing.append(global_name) if missing: raise ValueError(f"IK result is missing selected joints: {missing}") - return JointState({"name": list(resolved_joint_names), "position": selected_positions}) + return JointState({"name": list(global_joint_names), "position": selected_positions}) def normalize_joint_target_for_group( group: ResolvedPlanningGroup, target: JointState, ) -> JointState: - """Normalize a group joint target to resolved joint names in group order.""" + """Normalize a robot-scoped group target to global joint names in group order.""" if not target.name: if len(target.position) != len(group.joint_names): raise ValueError( @@ -118,20 +119,19 @@ def normalize_joint_target_for_group( ) return JointState(name=list(group.joint_names), position=list(target.position)) + assert_local_joint_names(target.name) positions_by_name = dict(zip(target.name, target.position, strict=False)) - resolved_positions: list[float] = [] + global_positions: list[float] = [] missing: list[str] = [] - for resolved_name, local_name in zip(group.joint_names, group.local_joint_names, strict=False): - if resolved_name in positions_by_name: - resolved_positions.append(positions_by_name[resolved_name]) - elif local_name in positions_by_name: - resolved_positions.append(positions_by_name[local_name]) + for local_name in group.local_joint_names: + if local_name in positions_by_name: + global_positions.append(positions_by_name[local_name]) else: - missing.append(resolved_name) + missing.append(local_name) if missing: raise ValueError(f"Target for '{group.id}' is missing joints: {missing}") - extra = set(target.name) - set(group.joint_names) - set(group.local_joint_names) + extra = set(target.name) - set(group.local_joint_names) if extra: raise ValueError(f"Target for '{group.id}' has extra joints: {sorted(extra)}") - return JointState(name=list(group.joint_names), position=resolved_positions) + return JointState(name=list(group.joint_names), position=global_positions) diff --git a/dimos/manipulation/planning/planning_identifiers.py b/dimos/manipulation/planning/planning_identifiers.py index afbc473ed5..2f7c479e5e 100644 --- a/dimos/manipulation/planning/planning_identifiers.py +++ b/dimos/manipulation/planning/planning_identifiers.py @@ -12,22 +12,41 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Planning group and resolved-joint identifier helpers.""" +"""Planning and joint identifier helpers.""" from __future__ import annotations +from collections.abc import Sequence + from dimos.manipulation.planning.spec.models import ( + GlobalJointName, LocalModelJointName, PlanningGroupID, - ResolvedJointName, RobotName, ) +def assert_valid_robot_name(robot_name: RobotName) -> None: + """Validate a robot name for delimiter-based public IDs.""" + if not robot_name or "/" in robot_name: + raise ValueError(f"Invalid robot name: {robot_name!r}") + + +def assert_valid_local_joint_name(local_joint_name: LocalModelJointName) -> None: + """Validate a local model joint name for delimiter-based global joint names.""" + if not local_joint_name or "/" in local_joint_name: + raise ValueError(f"Invalid local joint name: {local_joint_name!r}") + + +def assert_local_joint_names(names: Sequence[LocalModelJointName]) -> None: + """Validate that names are local model joint names, not global joint names.""" + for name in names: + assert_valid_local_joint_name(name) + + def make_planning_group_id(robot_name: RobotName, group_name: str) -> PlanningGroupID: """Build a public planning group ID.""" - if not robot_name or "/" in robot_name: - raise ValueError(f"Invalid robot name for planning group ID: {robot_name!r}") + assert_valid_robot_name(robot_name) if not group_name or "/" in group_name: raise ValueError(f"Invalid planning group name: {group_name!r}") return f"{robot_name}/{group_name}" @@ -43,46 +62,65 @@ def parse_planning_group_id(group_id: PlanningGroupID) -> tuple[RobotName, str]: return parts[0], parts[1] -def make_resolved_joint_name( +def make_global_joint_name( robot_name: RobotName, local_joint_name: LocalModelJointName, -) -> ResolvedJointName: - """Convert a local model joint name to a public resolved joint name.""" - if not robot_name or "/" in robot_name: - raise ValueError(f"Invalid robot name for resolved joint name: {robot_name!r}") - if not local_joint_name or "/" in local_joint_name: - raise ValueError(f"Invalid local joint name: {local_joint_name!r}") +) -> GlobalJointName: + """Convert a local model joint name to a public global joint name.""" + assert_valid_robot_name(robot_name) + assert_valid_local_joint_name(local_joint_name) return f"{robot_name}/{local_joint_name}" -def make_resolved_joint_names( +def make_global_joint_names( robot_name: RobotName, local_joint_names: list[LocalModelJointName] | tuple[LocalModelJointName, ...], -) -> list[ResolvedJointName]: - """Convert local model joint names to public resolved joint names.""" - return [make_resolved_joint_name(robot_name, name) for name in local_joint_names] +) -> list[GlobalJointName]: + """Convert local model joint names to public global joint names.""" + return [make_global_joint_name(robot_name, name) for name in local_joint_names] + + +def is_global_joint_name(name: str) -> bool: + """Return whether name has the exact global joint-name shape.""" + parts = name.split("/") + return len(parts) == 2 and bool(parts[0]) and bool(parts[1]) + + +def assert_global_joint_names(names: Sequence[GlobalJointName]) -> None: + """Validate that names are global joint names.""" + invalid = [name for name in names if not is_global_joint_name(name)] + if invalid: + raise ValueError(f"Expected global joint names; got invalid names: {invalid}") -def local_joint_name_from_resolved( +def local_joint_name_from_global( robot_name: RobotName, - resolved_joint_name: ResolvedJointName, + global_joint_name: GlobalJointName, ) -> LocalModelJointName: - """Validate and strip a resolved joint name for backend internals.""" + """Validate and strip a global joint name for backend internals.""" + assert_valid_robot_name(robot_name) prefix = f"{robot_name}/" - if not resolved_joint_name.startswith(prefix): + if not global_joint_name.startswith(prefix): raise ValueError( - f"Resolved joint name {resolved_joint_name!r} does not belong to robot {robot_name!r}" + f"Global joint name {global_joint_name!r} does not belong to robot {robot_name!r}" ) - local_name = resolved_joint_name[len(prefix) :] - if not local_name or "/" in local_name: - raise ValueError(f"Invalid resolved joint name: {resolved_joint_name!r}") + local_name = global_joint_name[len(prefix) :] + try: + assert_valid_local_joint_name(local_name) + except ValueError as exc: + raise ValueError(f"Invalid global joint name: {global_joint_name!r}") from exc return local_name __all__ = [ - "local_joint_name_from_resolved", + "assert_global_joint_names", + "assert_local_joint_names", + "assert_valid_local_joint_name", + "assert_valid_robot_name", + "is_global_joint_name", + "local_joint_name_from_global", + "make_global_joint_name", + "make_global_joint_names", "make_planning_group_id", - "make_resolved_joint_name", - "make_resolved_joint_names", "parse_planning_group_id", ] diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index b4c98d2275..1365e1c2bc 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -21,6 +21,10 @@ from pydantic import Field from dimos.core.module import ModuleConfig +from dimos.manipulation.planning.planning_identifiers import ( + assert_local_joint_names, + assert_valid_robot_name, +) from dimos.manipulation.planning.spec.models import PlanningGroupDefinition from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -35,8 +39,8 @@ class RobotModelConfig(ModuleConfig): base_pose: Compatibility placement transform used by current Drake world loading/welding. Prefer encoding new placement in the robot model when possible. - joint_names: Ordered list of controllable/coordinator joints in the - local model namespace. This is not a planning group. + joint_names: Ordered list of controllable joints in the local model + namespace. This is not a planning group. end_effector_link: Compatibility robot-scoped end-effector link used by legacy helpers. New pose-targeted planning should use planning group target frames instead. @@ -53,9 +57,6 @@ class RobotModelConfig(ModuleConfig): links may legitimately overlap (e.g., mimic joints). max_velocity: Maximum joint velocity for trajectory generation (rad/s) max_acceleration: Maximum joint acceleration for trajectory generation (rad/s^2) - joint_name_mapping: Maps coordinator joint names to URDF joint names. - Example: {"left/joint1": "joint1"} means coordinator's "left/joint1" - corresponds to URDF's "joint1". If empty, names are assumed to match. coordinator_task_name: Task name for executing trajectories via coordinator RPC. If set, trajectories can be executed via execute_trajectory() RPC. """ @@ -79,7 +80,6 @@ class RobotModelConfig(ModuleConfig): max_velocity: float = 1.0 max_acceleration: float = 2.0 # Coordinator integration - joint_name_mapping: dict[str, str] = Field(default_factory=dict) coordinator_task_name: str | None = None gripper_hardware_id: str | None = None # TF publishing for extra links (e.g., camera mount) @@ -89,19 +89,7 @@ class RobotModelConfig(ModuleConfig): # Pre-grasp offset distance in meters (along approach direction) pre_grasp_offset: float = 0.10 - def get_urdf_joint_name(self, coordinator_name: str) -> str: - """Translate coordinator joint name to URDF joint name.""" - return self.joint_name_mapping.get(coordinator_name, coordinator_name) - - def get_coordinator_joint_name(self, urdf_name: str) -> str: - """Translate URDF joint name to coordinator joint name.""" - for coord_name, u_name in self.joint_name_mapping.items(): - if u_name == urdf_name: - return coord_name - return urdf_name - - def get_coordinator_joint_names(self) -> list[str]: - """Get joint names in coordinator namespace.""" - if not self.joint_name_mapping: - return self.joint_names - return [self.get_coordinator_joint_name(j) for j in self.joint_names] + def model_post_init(self, __context: object) -> None: + """Validate delimiter-based naming constraints.""" + assert_valid_robot_name(self.name) + assert_local_joint_names(self.joint_names) diff --git a/dimos/manipulation/planning/spec/models.py b/dimos/manipulation/planning/spec/models.py index 8bd91070ce..d7a226d325 100644 --- a/dimos/manipulation/planning/spec/models.py +++ b/dimos/manipulation/planning/spec/models.py @@ -45,7 +45,7 @@ LocalModelJointName: TypeAlias = str """Joint name as it appears in URDF/SRDF before world binding.""" -ResolvedJointName: TypeAlias = str +GlobalJointName: TypeAlias = str """Public joint name of the form {robot_name}/{local_joint_name}.""" JointPath: TypeAlias = "list[JointState]" @@ -85,14 +85,14 @@ class PlanningGroupDescriptor: """Read-only public snapshot for an available planning group. Descriptors are returned by query APIs. They expose stable public IDs and - resolved joint names for callers, but intentionally do not expose backend + global joint names for callers, but intentionally do not expose backend runtime IDs or mutable world state. """ id: PlanningGroupID robot_name: RobotName group_name: str - joint_names: tuple[ResolvedJointName, ...] + joint_names: tuple[GlobalJointName, ...] local_joint_names: tuple[LocalModelJointName, ...] base_link: str tip_link: str | None = None @@ -117,7 +117,7 @@ class ResolvedPlanningGroup: robot_id: WorldRobotID robot_name: RobotName group_name: str - joint_names: tuple[ResolvedJointName, ...] + joint_names: tuple[GlobalJointName, ...] local_joint_names: tuple[LocalModelJointName, ...] base_link: str tip_link: str | None = None @@ -133,7 +133,7 @@ def has_pose_target(self) -> bool: class GeneratedPlan: """Canonical generated planning artifact. - The path uses resolved joint names and contains exactly the selected joints. + The path uses global joint names and contains exactly the selected joints. Downstream preview/execution projections are computed lazily from this data. """ diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index d36b5ab0c4..851bc5a2e7 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -79,7 +79,7 @@ def list_planning_groups(self) -> tuple[PlanningGroupDescriptor, ...]: SRDF/fallback parsing creates model-level definitions before world binding. This query returns world-level descriptor snapshots with stable - public IDs and resolved joint names. + public IDs and global joint names. """ ... diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 32d1d8fcf6..666672b048 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -26,10 +26,10 @@ import numpy as np from dimos.manipulation.planning.planning_identifiers import ( - local_joint_name_from_resolved, + assert_local_joint_names, + make_global_joint_name, + make_global_joint_names, make_planning_group_id, - make_resolved_joint_name, - make_resolved_joint_names, parse_planning_group_id, ) from dimos.manipulation.planning.spec.config import RobotModelConfig @@ -360,9 +360,7 @@ def list_planning_groups(self) -> tuple[PlanningGroupDescriptor, ...]: id=make_planning_group_id(config.name, group.name), robot_name=config.name, group_name=group.name, - joint_names=tuple( - make_resolved_joint_names(config.name, group.joint_names) - ), + joint_names=tuple(make_global_joint_names(config.name, group.joint_names)), local_joint_names=group.joint_names, base_link=group.base_link, tip_link=group.tip_link, @@ -392,14 +390,14 @@ def resolve_planning_groups( if group is None: raise KeyError(f"Unknown planning group ID: {group_id}") - resolved_joint_names = tuple( - make_resolved_joint_name(robot_name, local_name) for local_name in group.joint_names + global_joint_names = tuple( + make_global_joint_name(robot_name, local_name) for local_name in group.joint_names ) - for joint_name in resolved_joint_names: + for joint_name in global_joint_names: previous_group_id = seen_joints.get(joint_name) if previous_group_id is not None: raise ValueError( - "Selected planning groups overlap on resolved joint " + "Selected planning groups overlap on global joint " f"{joint_name}: {previous_group_id} and {group_id}" ) seen_joints[joint_name] = group_id @@ -410,7 +408,7 @@ def resolve_planning_groups( robot_id=robot_data.robot_id, robot_name=robot_name, group_name=group_name, - joint_names=resolved_joint_names, + joint_names=global_joint_names, local_joint_names=group.joint_names, base_link=group.base_link, tip_link=group.tip_link, @@ -946,9 +944,9 @@ def _positions_for_robot_state( if len(joint_state.name) != len(joint_state.position): raise ValueError("JointState name and position lengths must match") + assert_local_joint_names(joint_state.name) for name, position in zip(joint_state.name, joint_state.position, strict=False): - local_name = self._state_name_to_local(robot_data.config, name) - state_by_local_name[local_name] = float(position) + state_by_local_name[name] = float(position) missing = [name for name in local_joint_names if name not in state_by_local_name] if missing: @@ -957,12 +955,6 @@ def _positions_for_robot_state( ) return np.array([state_by_local_name[name] for name in local_joint_names], dtype=np.float64) - def _state_name_to_local(self, config: RobotModelConfig, joint_name: str) -> str: - try: - return local_joint_name_from_resolved(config.name, joint_name) - except ValueError: - return config.get_urdf_joint_name(joint_name) - def get_joint_state(self, ctx: Context, robot_id: WorldRobotID) -> JointState: """Get robot joint state from given context.""" if not self._finalized: @@ -982,7 +974,7 @@ def get_joint_state(self, ctx: Context, robot_id: WorldRobotID) -> JointState: positions = [float(full_positions[idx]) for idx in robot_data.joint_indices] return JointState( - name=make_resolved_joint_names(robot_data.config.name, robot_data.config.joint_names), + name=make_global_joint_names(robot_data.config.name, robot_data.config.joint_names), position=positions, ) diff --git a/dimos/manipulation/planning/world/test_drake_world_planning_groups.py b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py index d4d4f4867e..adc04aaae6 100644 --- a/dimos/manipulation/planning/world/test_drake_world_planning_groups.py +++ b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py @@ -36,7 +36,6 @@ def _config( name: str, joint_names: list[str], groups: list[PlanningGroupDefinition], - joint_name_mapping: dict[str, str] | None = None, ) -> RobotModelConfig: return RobotModelConfig( name=name, @@ -46,7 +45,6 @@ def _config( end_effector_link="tool0", base_link="base_link", planning_groups=groups, - joint_name_mapping=joint_name_mapping or {}, ) @@ -76,7 +74,7 @@ def _arm_group(*joint_names: str) -> PlanningGroupDefinition: ) -def test_list_planning_groups_returns_stable_ids_and_resolved_joint_names() -> None: +def test_list_planning_groups_returns_stable_ids_and_global_joint_names() -> None: world = _world(_config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")])) groups = world.list_planning_groups() @@ -161,29 +159,21 @@ def test_resolve_planning_groups_overlapping_same_robot_groups_raise_value_error world.resolve_planning_groups(("left/arm", "left/wrist")) -def test_positions_for_robot_state_accepts_resolved_joint_names_in_config_order() -> None: +def test_positions_for_robot_state_accepts_local_joint_names_in_config_order() -> None: world = _world(_config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")])) - joint_state = JointState({"name": ["left/joint2", "left/joint1"], "position": [2.0, 1.0]}) + joint_state = JointState({"name": ["joint2", "joint1"], "position": [2.0, 1.0]}) positions = world._positions_for_robot_state("robot_1", joint_state) np.testing.assert_allclose(positions, np.array([1.0, 2.0])) -def test_positions_for_robot_state_falls_back_to_coordinator_mapping() -> None: - world = _world( - _config( - "left", - ["urdf_joint1", "urdf_joint2"], - [_arm_group("urdf_joint1", "urdf_joint2")], - joint_name_mapping={"coord_joint1": "urdf_joint1", "coord_joint2": "urdf_joint2"}, - ) - ) - joint_state = JointState({"name": ["coord_joint2", "coord_joint1"], "position": [2.0, 1.0]}) - - positions = world._positions_for_robot_state("robot_1", joint_state) +def test_positions_for_robot_state_rejects_global_joint_names() -> None: + world = _world(_config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")])) + joint_state = JointState({"name": ["left/joint2", "left/joint1"], "position": [2.0, 1.0]}) - np.testing.assert_allclose(positions, np.array([1.0, 2.0])) + with pytest.raises(ValueError, match="Invalid local joint name: 'left/joint2'"): + world._positions_for_robot_state("robot_1", joint_state) def test_group_pose_rejects_group_without_target_frame() -> None: diff --git a/dimos/manipulation/test_manipulation_module.py b/dimos/manipulation/test_manipulation_module.py index 06970258b7..bc33aaefa3 100644 --- a/dimos/manipulation/test_manipulation_module.py +++ b/dimos/manipulation/test_manipulation_module.py @@ -69,15 +69,6 @@ def _get_xarm7_config() -> RobotModelConfig: auto_convert_meshes=True, max_velocity=1.0, max_acceleration=2.0, - joint_name_mapping={ - "arm/joint1": "joint1", - "arm/joint2": "joint2", - "arm/joint3": "joint3", - "arm/joint4": "joint4", - "arm/joint5": "joint5", - "arm/joint6": "joint6", - "arm/joint7": "joint7", - }, coordinator_task_name="traj_arm", ) @@ -92,13 +83,13 @@ def joint_state_zeros(): """Create a JointState message with zeros for XArm7.""" return JointState( name=[ - "arm/joint1", - "arm/joint2", - "arm/joint3", - "arm/joint4", - "arm/joint5", - "arm/joint6", - "arm/joint7", + "test_arm/joint1", + "test_arm/joint2", + "test_arm/joint3", + "test_arm/joint4", + "test_arm/joint5", + "test_arm/joint6", + "test_arm/joint7", ], position=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], velocity=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], @@ -191,7 +182,6 @@ def test_robot_info(self, module): assert len(info["joint_names"]) == 7 assert info["end_effector_link"] == "link7" assert info["coordinator_task_name"] == "traj_arm" - assert info["has_joint_name_mapping"] is True def test_ee_pose(self, module, joint_state_zeros): """Test getting end-effector pose.""" @@ -204,20 +194,15 @@ def test_ee_pose(self, module, joint_state_zeros): assert hasattr(pose, "y") assert hasattr(pose, "z") - def test_trajectory_name_translation(self, module, joint_state_zeros): - """Test that trajectory joint names are translated for coordinator.""" + def test_planned_trajectory_uses_global_joint_names(self, module, joint_state_zeros): + """Test that planned trajectory joint names are global for coordinator.""" module._on_joint_state(joint_state_zeros) success = module.plan_to_joints(JointState(position=[0.05] * 7)) assert success is True traj = module._planned_trajectories["test_arm"] - robot_config = module._robots["test_arm"][1] - - translated = module._translate_trajectory_to_coordinator(traj, robot_config) - - for name in translated.joint_names: - assert name.startswith("arm_") # Should have arm_ prefix + assert traj.joint_names == [f"test_arm/joint{i}" for i in range(1, 8)] @pytest.mark.skipif(not _drake_available(), reason="Drake not installed") @@ -251,8 +236,7 @@ def test_execute_with_mock_coordinator(self, module, joint_state_zeros): assert method_name == "execute" trajectory = kwargs["trajectory"] assert len(trajectory.points) > 1 - # Joint names should be translated - assert all(n.startswith("arm_") for n in trajectory.joint_names) + assert trajectory.joint_names == [f"test_arm/joint{i}" for i in range(1, 8)] def test_execute_rejected_by_coordinator(self, module, joint_state_zeros): """Test handling of coordinator rejection.""" diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index d01d711cb6..a1f279d8be 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -63,8 +63,8 @@ def robot_config(): @pytest.fixture -def robot_config_with_mapping(): - """Create a robot config with joint name mapping (dual-arm scenario).""" +def left_robot_config(): + """Create a robot config for a scoped left arm.""" return RobotModelConfig( name="left_arm", model_path=Path("/path/to/robot.urdf"), @@ -72,11 +72,6 @@ def robot_config_with_mapping(): joint_names=["joint1", "joint2", "joint3"], end_effector_link="link_tcp", base_link="link_base", - joint_name_mapping={ - "left/joint1": "joint1", - "left/joint2": "joint2", - "left/joint3": "joint3", - }, coordinator_task_name="traj_left", ) @@ -347,27 +342,6 @@ def test_solve_ik_rpc_uses_explicit_seed(self, robot_config): module._world_monitor.get_current_joint_state.assert_not_called() -class TestJointNameTranslation: - """Test trajectory joint name translation for coordinator.""" - - def test_no_mapping_returns_original(self, robot_config, simple_trajectory): - """Without mapping, trajectory is returned unchanged.""" - module = _make_module() - - result = module._translate_trajectory_to_coordinator(simple_trajectory, robot_config) - assert result is simple_trajectory # Same object - - def test_mapping_translates_names(self, robot_config_with_mapping, simple_trajectory): - """With mapping, joint names are translated.""" - module = _make_module() - - result = module._translate_trajectory_to_coordinator( - simple_trajectory, robot_config_with_mapping - ) - assert result.joint_names == ["left/joint1", "left/joint2", "left/joint3"] - assert len(result.points) == 2 # Points preserved - - class TestExecute: """Test coordinator execution.""" @@ -398,7 +372,11 @@ def test_execute_success(self, robot_config, simple_trajectory): """Successful execute calls coordinator via task_invoke.""" module = _make_module() module._robots = {"test_arm": ("id", robot_config, MagicMock())} - module._planned_trajectories = {"test_arm": simple_trajectory} + global_trajectory = JointTrajectory( + joint_names=["test_arm/joint1", "test_arm/joint2", "test_arm/joint3"], + points=simple_trajectory.points, + ) + module._planned_trajectories = {"test_arm": global_trajectory} mock_client = MagicMock() mock_client.task_invoke.return_value = True @@ -407,7 +385,7 @@ def test_execute_success(self, robot_config, simple_trajectory): assert module.execute() is True assert module._state == ManipulationState.COMPLETED mock_client.task_invoke.assert_called_once_with( - "traj_arm", "execute", {"trajectory": simple_trajectory} + "traj_arm", "execute", {"trajectory": global_trajectory} ) def test_execute_rejected(self, robot_config, simple_trajectory): @@ -424,22 +402,6 @@ def test_execute_rejected(self, robot_config, simple_trajectory): assert module._state == ManipulationState.FAULT -class TestRobotModelConfigMapping: - """Test RobotModelConfig joint name mapping helpers.""" - - def test_bidirectional_mapping(self, robot_config_with_mapping): - """Test URDF <-> coordinator name translation.""" - config = robot_config_with_mapping - - # Coordinator -> URDF - assert config.get_urdf_joint_name("left/joint1") == "joint1" - assert config.get_urdf_joint_name("unknown") == "unknown" - - # URDF -> Coordinator - assert config.get_coordinator_joint_name("joint1") == "left/joint1" - assert config.get_coordinator_joint_name("unknown") == "unknown" - - def _make_module_with_monitor(*configs: RobotModelConfig) -> ManipulationModule: """Create a ManipulationModule with a mocked world monitor and robots configured.""" module = _make_module() @@ -473,7 +435,6 @@ def _make_trajectory(*points: tuple[float, list[float]]) -> JointTrajectory: def _make_robot_config( name: str, joints: list[str], - coordinator_joints: list[str], task_name: str, ) -> RobotModelConfig: return RobotModelConfig( @@ -483,12 +444,11 @@ def _make_robot_config( joint_names=joints, end_effector_link="ee", base_link="base", - joint_name_mapping=dict(zip(coordinator_joints, joints, strict=True)), coordinator_task_name=task_name, ) -def _make_resolved_group( +def _make_global_group( robot_name: str, group_name: str, joints: list[str] ) -> ResolvedPlanningGroup: return ResolvedPlanningGroup( @@ -541,12 +501,12 @@ def _make_world_monitor_with_viz(viz: object | None) -> WorldMonitor: class TestOnJointState: """Test _on_joint_state routing, splitting, and init capture.""" - def test_routes_positions_to_monitor(self, robot_config_with_mapping): + def test_routes_positions_to_monitor(self, left_robot_config): """Joint positions from aggregated message are routed to the correct monitor.""" - module = _make_module_with_monitor(robot_config_with_mapping) + module = _make_module_with_monitor(left_robot_config) msg = JointState( - name=["left/joint1", "left/joint2", "left/joint3"], + name=["left_arm/joint1", "left_arm/joint2", "left_arm/joint3"], position=[0.1, 0.2, 0.3], velocity=[1.0, 2.0, 3.0], ) @@ -556,13 +516,14 @@ def test_routes_positions_to_monitor(self, robot_config_with_mapping): module._world_monitor.on_joint_state.assert_called_once() call_args = module._world_monitor.on_joint_state.call_args sub_msg = call_args[0][0] + assert sub_msg.name == ["joint1", "joint2", "joint3"] assert sub_msg.position == [0.1, 0.2, 0.3] assert sub_msg.velocity == [1.0, 2.0, 3.0] assert call_args[1]["robot_id"] == "robot_left_arm" - def test_skips_robot_with_missing_joints(self, robot_config_with_mapping): + def test_skips_robot_with_missing_joints(self, left_robot_config): """Robots whose joints are absent from the message are skipped.""" - module = _make_module_with_monitor(robot_config_with_mapping) + module = _make_module_with_monitor(left_robot_config) # Message has none of left_arm's joints msg = JointState( @@ -573,12 +534,12 @@ def test_skips_robot_with_missing_joints(self, robot_config_with_mapping): module._world_monitor.on_joint_state.assert_not_called() - def test_captures_init_joints_on_first_call(self, robot_config_with_mapping): + def test_captures_init_joints_on_first_call(self, left_robot_config): """First joint state is stored as init joints; subsequent calls don't overwrite.""" - module = _make_module_with_monitor(robot_config_with_mapping) + module = _make_module_with_monitor(left_robot_config) first_msg = JointState( - name=["left/joint1", "left/joint2", "left/joint3"], + name=["left_arm/joint1", "left_arm/joint2", "left_arm/joint3"], position=[0.1, 0.2, 0.3], ) module._on_joint_state(first_msg) @@ -587,7 +548,7 @@ def test_captures_init_joints_on_first_call(self, robot_config_with_mapping): # Second call should NOT overwrite second_msg = JointState( - name=["left/joint1", "left/joint2", "left/joint3"], + name=["left_arm/joint1", "left_arm/joint2", "left_arm/joint3"], position=[0.9, 0.8, 0.7], ) module._on_joint_state(second_msg) @@ -602,7 +563,6 @@ def test_multi_robot_splits_correctly(self): joint_names=["j1", "j2"], end_effector_link="ee", base_link="base", - joint_name_mapping={"left/j1": "j1", "left/j2": "j2"}, coordinator_task_name="traj_left", ) right_config = RobotModelConfig( @@ -612,7 +572,6 @@ def test_multi_robot_splits_correctly(self): joint_names=["j1", "j2"], end_effector_link="ee", base_link="base", - joint_name_mapping={"right/j1": "j1", "right/j2": "j2"}, coordinator_task_name="traj_right", ) module = _make_module_with_monitor(left_config, right_config) @@ -636,15 +595,15 @@ def test_multi_robot_splits_correctly(self): assert calls["robot_left"].velocity == [0.1, 0.2] assert calls["robot_right"].velocity == [0.3, 0.4] - def test_no_monitor_returns_early(self, robot_config_with_mapping): + def test_no_monitor_returns_early(self, left_robot_config): """When world_monitor is None, _on_joint_state returns without error.""" module = _make_module() - module._robots = {"left_arm": ("id", robot_config_with_mapping, MagicMock())} + module._robots = {"left_arm": ("id", left_robot_config, MagicMock())} module._world_monitor = None # Should not raise msg = JointState( - name=["left/joint1", "left/joint2", "left/joint3"], + name=["left_arm/joint1", "left_arm/joint2", "left_arm/joint3"], position=[0.1, 0.2, 0.3], ) module._on_joint_state(msg) @@ -782,10 +741,10 @@ def test_preview_path_returns_false_for_missing_inputs(self): class TestGeneratedPlanProjection: def test_selected_joint_state_accepts_local_current_state_names(self): - config = _make_robot_config("left", ["j1", "j2"], ["c/j1", "c/j2"], "task") + config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_resolved_group("left", "arm", ["j1", "j2"]) + _make_global_group("left", "arm", ["j1", "j2"]) ] module._world_monitor.get_current_joint_state.return_value = JointState( name=["j1", "j2"], position=[1.0, 2.0] @@ -801,24 +760,21 @@ def test_execute_plan_dispatches_one_trajectory_per_affected_robot(self): left_config = _make_robot_config( "left", ["j1", "j2", "j3"], - ["left_task/j1", "left_task/j2", "left_task/j3"], "left_task", ) - right_config = _make_robot_config( - "right", ["j1", "j2"], ["right_task/j1", "right_task/j2"], "right_task" - ) + right_config = _make_robot_config("right", ["j1", "j2"], "right_task") module = _make_module_with_monitor(left_config, right_config) left_gen = _trajectory_generator() right_gen = _trajectory_generator() module._robots["left"] = ("robot_left", left_config, left_gen) module._robots["right"] = ("robot_right", right_config, right_gen) module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_resolved_group("left", "arm", ["j1", "j2"]), - _make_resolved_group("right", "arm", ["j1"]), + _make_global_group("left", "arm", ["j1", "j2"]), + _make_global_group("right", "arm", ["j1"]), ] module._world_monitor.get_current_joint_state.side_effect = [ - JointState(name=["left/j1", "left/j2", "left/j3"], position=[0.0, 0.0, 9.0]), - JointState(name=["right/j1", "right/j2"], position=[0.0, 8.0]), + JointState(name=["j1", "j2", "j3"], position=[0.0, 0.0, 9.0]), + JointState(name=["j1", "j2"], position=[0.0, 8.0]), ] module._coordinator_client = MagicMock() module._coordinator_client.task_invoke.return_value = True @@ -830,21 +786,21 @@ def test_execute_plan_dispatches_one_trajectory_per_affected_robot(self): left_call, right_call = module._coordinator_client.task_invoke.call_args_list assert left_call.args[0:2] == ("left_task", "execute") left_trajectory = left_call.args[2]["trajectory"] - assert left_trajectory.joint_names == ["left_task/j1", "left_task/j2", "left_task/j3"] + assert left_trajectory.joint_names == ["left/j1", "left/j2", "left/j3"] assert [point.positions for point in left_trajectory.points] == [ [1.0, 2.0, 9.0], [4.0, 5.0, 9.0], ] assert right_call.args[0:2] == ("right_task", "execute") right_trajectory = right_call.args[2]["trajectory"] - assert right_trajectory.joint_names == ["right_task/j1", "right_task/j2"] + assert right_trajectory.joint_names == ["right/j1", "right/j2"] assert [point.positions for point in right_trajectory.points] == [[3.0, 8.0], [6.0, 8.0]] def test_project_plan_holds_non_selected_joints_from_current_state(self): - config = _make_robot_config("left", ["j1", "j2", "j3"], ["c/j1", "c/j2", "c/j3"], "task") + config = _make_robot_config("left", ["j1", "j2", "j3"], "task") module = _make_module_with_monitor(config) module._world_monitor.get_current_joint_state.return_value = JointState( - name=["left/j1", "left/j2", "left/j3"], position=[10.0, 20.0, 30.0] + name=["j1", "j2", "j3"], position=[10.0, 20.0, 30.0] ) plan = GeneratedPlan( group_ids=("left/arm",), @@ -861,14 +817,14 @@ def test_project_plan_holds_non_selected_joints_from_current_state(self): assert [state.position for state in projected] == [[10.0, 2.0, 30.0], [10.0, 3.0, 30.0]] def test_preview_path_with_last_plan_projects_lazily_to_world_monitor(self): - config = _make_robot_config("left", ["j1", "j2"], ["c/j1", "c/j2"], "task") + config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) module._robots["left"] = ("robot_left", config, _trajectory_generator()) module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_resolved_group("left", "arm", ["j1"]) + _make_global_group("left", "arm", ["j1"]) ] module._world_monitor.get_current_joint_state.return_value = JointState( - name=["left/j1", "left/j2"], position=[0.0, 7.0] + name=["j1", "j2"], position=[0.0, 7.0] ) module._last_plan = GeneratedPlan( group_ids=("left/arm",), diff --git a/dimos/robot/catalog/openarm.py b/dimos/robot/catalog/openarm.py index b6e1238cf2..fb57e013b5 100644 --- a/dimos/robot/catalog/openarm.py +++ b/dimos/robot/catalog/openarm.py @@ -65,9 +65,6 @@ def openarm_arm( "adapter_type": adapter_type, "address": address, "joint_names": joint_names, - # URDF already prefixes joints with "left_"/"right_" in bimanual mode, - # so suppress RobotConfig's automatic "{name}_" prefix. - "joint_prefix": "", "base_link": "openarm_body_link0", "home_joints": [0.0] * 7, "base_pose": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], @@ -104,7 +101,6 @@ def openarm_single( "adapter_type": adapter_type, "address": address, "joint_names": [f"openarm_left_joint{i}" for i in range(1, 8)], - "joint_prefix": "", "base_link": "openarm_body_link0", "home_joints": [0.0] * 7, "package_paths": {"openarm_description": _OPENARM_PKG}, diff --git a/dimos/robot/config.py b/dimos/robot/config.py index 8dabdf24e1..829b7d646d 100644 --- a/dimos/robot/config.py +++ b/dimos/robot/config.py @@ -29,6 +29,7 @@ from dimos.control.components import HardwareComponent, HardwareType from dimos.control.coordinator import TaskConfig from dimos.manipulation.planning.planning_groups import discover_planning_group_definitions +from dimos.manipulation.planning.planning_identifiers import make_global_joint_names from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -82,8 +83,6 @@ class RobotConfig(BaseModel): ) home_joints: list[float] | None = None - # Multi-robot / coordinator - joint_prefix: str | None = None # defaults to "{name}_" # Compatibility planning placement. Prefer encoding placement in URDF/xacro/MJCF. base_pose: list[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) @@ -113,11 +112,6 @@ class RobotConfig(BaseModel): _parsed: ModelDescription | None = PrivateAttr(default=None) - def _ensure_prefix(self) -> None: - """Ensure joint_prefix is set (no model parsing needed).""" - if self.joint_prefix is None: - self.joint_prefix = f"{self.name}/" - def _ensure_parsed(self) -> ModelDescription: """Parse model lazily on first access.""" if self._parsed is None: @@ -127,7 +121,6 @@ def _ensure_parsed(self) -> ModelDescription: "joint/link info is unavailable. Set model_path to a URDF/MJCF." ) self._parsed = parse_model(self.model_path, self.package_paths, self.xacro_args) - self._ensure_prefix() if self.joint_names is None: self.joint_names = self._parsed.actuated_joint_names if self.base_link is None: @@ -139,7 +132,7 @@ def _ensure_parsed(self) -> ModelDescription: def _compute_default_home(self) -> list[float]: assert self._parsed is not None home = [] - for joint_name in self.resolved_joint_names: + for joint_name in self.local_joint_names: joint = self._parsed.get_joint(joint_name) if ( joint is not None @@ -158,11 +151,15 @@ def model_description(self) -> ModelDescription: return self._ensure_parsed() @property - def resolved_joint_names(self) -> list[str]: + def local_joint_names(self) -> list[str]: self._ensure_parsed() assert self.joint_names is not None return self.joint_names + @property + def global_joint_names(self) -> list[str]: + return make_global_joint_names(self.name, self.local_joint_names) + @property def resolved_base_link(self) -> str: self._ensure_parsed() @@ -173,23 +170,7 @@ def resolved_base_link(self) -> str: def dof(self) -> int: if self.joint_names is not None: return len(self.joint_names) - return len(self.resolved_joint_names) - - @property - def coordinator_joint_names(self) -> list[str]: - self._ensure_prefix() - names = self.joint_names if self.joint_names is not None else self.resolved_joint_names - if not self.joint_prefix: - return list(names) - return [f"{self.joint_prefix}{j}" for j in names] - - @property - def joint_name_mapping(self) -> dict[str, str]: - self._ensure_prefix() - names = self.joint_names if self.joint_names is not None else self.resolved_joint_names - if not self.joint_prefix: - return {} - return {f"{self.joint_prefix}{j}": j for j in names} + return len(self.local_joint_names) @property def coordinator_task_name(self) -> str: @@ -215,9 +196,7 @@ def to_robot_model_config(self) -> RobotModelConfig: exclusions.extend(self.gripper.collision_exclusions) # Use direct fields when available to avoid triggering model parsing at import time - joint_names = ( - self.joint_names if self.joint_names is not None else self.resolved_joint_names - ) + joint_names = self.joint_names if self.joint_names is not None else self.local_joint_names base_link = self.base_link if self.base_link is not None else self.resolved_base_link planning_groups = discover_planning_group_definitions( robot_name=self.name, @@ -246,7 +225,6 @@ def to_robot_model_config(self) -> RobotModelConfig: auto_convert_meshes=self.auto_convert_meshes, max_velocity=self.max_velocity, max_acceleration=self.max_acceleration, - joint_name_mapping=self.joint_name_mapping, coordinator_task_name=self.coordinator_task_name, gripper_hardware_id=self.name if self.gripper else None, tf_extra_links=self.tf_extra_links, @@ -256,10 +234,9 @@ def to_robot_model_config(self) -> RobotModelConfig: def to_hardware_component(self) -> HardwareComponent: """Generate HardwareComponent for ControlCoordinator.""" - self._ensure_prefix() gripper_joints: list[str] = [] if self.gripper and self.gripper.joints: - gripper_joints = [f"{self.joint_prefix}{j}" for j in self.gripper.joints] + gripper_joints = make_global_joint_names(self.name, self.gripper.joints) adapter_kwargs = dict(self.adapter_kwargs) if self.home_joints is not None: @@ -268,7 +245,7 @@ def to_hardware_component(self) -> HardwareComponent: return HardwareComponent( hardware_id=self.name, hardware_type=HardwareType.MANIPULATOR, - joints=self.coordinator_joint_names, + joints=self.global_joint_names, adapter_type=self.adapter_type, address=self.address, auto_enable=self.auto_enable, @@ -300,7 +277,7 @@ def to_task_config( return TaskConfig( name=task_name if task_name is not None else self.coordinator_task_name, type=task_type if task_type is not None else self.task_type, - joint_names=self.coordinator_joint_names, + joint_names=self.global_joint_names, priority=priority if priority is not None else self.task_priority, auto_start=auto_start, params=params, diff --git a/dimos/robot/manipulators/xarm/blueprints.py b/dimos/robot/manipulators/xarm/blueprints.py index 6091f83030..09cd5de94b 100644 --- a/dimos/robot/manipulators/xarm/blueprints.py +++ b/dimos/robot/manipulators/xarm/blueprints.py @@ -56,7 +56,7 @@ KeyboardTeleopModule.blueprint( model_path=XARM6_FK_MODEL, ee_joint_id=_xarm6_cfg.dof, - joint_names=_xarm6_cfg.coordinator_joint_names, + joint_names=_xarm6_cfg.global_joint_names, ), ControlCoordinator.blueprint( tick_rate=100.0, @@ -90,7 +90,7 @@ KeyboardTeleopModule.blueprint( model_path=XARM7_FK_MODEL, ee_joint_id=_xarm7_cfg.dof, - joint_names=_xarm7_cfg.coordinator_joint_names, + joint_names=_xarm7_cfg.global_joint_names, ), ControlCoordinator.blueprint( tick_rate=100.0, diff --git a/docs/capabilities/manipulation/adding_a_custom_arm.md b/docs/capabilities/manipulation/adding_a_custom_arm.md index a90dff7a80..b7d204fd18 100644 --- a/docs/capabilities/manipulation/adding_a_custom_arm.md +++ b/docs/capabilities/manipulation/adding_a_custom_arm.md @@ -533,7 +533,6 @@ def _make_base_pose(x=0.0, y=0.0, z=0.0) -> PoseStamped: def _make_yourarm_config( name: str = "arm", y_offset: float = 0.0, - joint_prefix: str = "", coordinator_task: str | None = None, ) -> RobotModelConfig: """Create YourArm robot config for planning. @@ -541,12 +540,10 @@ def _make_yourarm_config( Args: name: Robot name in the Drake planning world. y_offset: Y-axis offset for multi-arm setups. - joint_prefix: Prefix for joint name mapping to coordinator namespace. coordinator_task: Coordinator task name for trajectory execution via RPC. """ # These must match the joint names in your URDF joint_names = ["joint1", "joint2", "joint3", "joint4", "joint5", "joint6"] - joint_mapping = {f"{joint_prefix}{j}": j for j in joint_names} if joint_prefix else {} return RobotModelConfig( name=name, @@ -569,7 +566,6 @@ def _make_yourarm_config( auto_convert_meshes=True, # Convert DAE/STL meshes for Drake max_velocity=1.0, # Max velocity scaling factor max_acceleration=2.0, # Max acceleration scaling factor - joint_name_mapping=joint_mapping, coordinator_task_name=coordinator_task, ) ``` @@ -581,7 +577,7 @@ Add this to your `dimos/robot/yourarm/blueprints.py` alongside the coordinator b ```python skip yourarm_planner = manipulation_module( - robots=[_make_yourarm_config("arm", joint_prefix="arm_", coordinator_task="traj_arm")], + robots=[_make_yourarm_config("arm", coordinator_task="traj_arm")], planning_timeout=10.0, enable_viz=True, ).transports( @@ -596,13 +592,17 @@ yourarm_planner = manipulation_module( | Field | Description | |-------|-------------| | `model_path` | Path to `.urdf` or `.xacro` file | -| `joint_names` | Ordered controllable/coordinator joint set (must match URDF); not itself a planning group | +| `joint_names` | Ordered controllable local model joint set (must match URDF); not itself a planning group | | `planning_groups` / `srdf_path` | Explicit planning groups or SRDF source; fallback can generate `{robot_name}/manipulator` for an unambiguous single chain | | `package_paths` | Maps `package://` URIs to filesystem paths (for xacro) | -| `joint_name_mapping` | Maps coordinator names (e.g., `"arm_joint1"`) to URDF names (e.g., `"joint1"`) | | `coordinator_task_name` | Must match the `TaskConfig.name` in your coordinator blueprint | | `collision_exclusion_pairs` | List of `(link_a, link_b)` tuples for links that may legitimately touch (e.g., gripper fingers) | +Coordinator-facing joint states and trajectories use global joint names derived +mechanically as `{robot_name}/{local_joint_name}` (for example, `arm/joint1`). +Keep hardware-native name translation inside the hardware adapter; manipulation +planning config uses local model joint names. + `base_link`, `base_pose`, and `end_effector_link` are compatibility fields used by current placement and robot-scoped helper paths. New planning code should use SRDF/planning-group chain base/tip links and encode robot placement in the model. See diff --git a/docs/capabilities/manipulation/planning_groups.md b/docs/capabilities/manipulation/planning_groups.md index 80dc9e6e0f..24343b5bc1 100644 --- a/docs/capabilities/manipulation/planning_groups.md +++ b/docs/capabilities/manipulation/planning_groups.md @@ -10,13 +10,13 @@ being planned. |---------|---------| | Planning group | A named serial chain of controllable robot joints. | | Planning group ID | Stable API ID in the form `{robot_name}/{group_name}`. | -| Resolved joint name | World-level joint name in the form `{robot_name}/{local_joint_name}`. | -| Generated plan | Minimal planning artifact containing selected group IDs and one synchronized resolved-joint path. | +| Global joint name | Boundary-level joint name in the form `{robot_name}/{local_joint_name}`. | +| Generated plan | Minimal planning artifact containing selected group IDs and one synchronized global-joint path. | | Auxiliary group | A group selected for a pose request without receiving its own pose target. | -Local URDF/SRDF joint names stay inside model parsing and backend internals. -Public planning states and generated plan paths use resolved joint names so two -robots can safely have the same local joint names. +Local URDF/SRDF joint names stay inside robot-scoped APIs, model parsing, and +backend internals. Flat planning states and generated plan paths require global +joint names so two robots can safely have the same local joint names. ## Planning group sources @@ -53,8 +53,8 @@ unique serial target frame. ## Fallback behavior When no SRDF is available, fallback uses `RobotModelConfig.joint_names` as the -candidate controllable set. This field is the robot's controllable/coordinator -joint set, not an implicit planning group. +candidate controllable set. This field is the robot's ordered local model joint +set, not an implicit planning group. Fallback succeeds only when those joints form one unambiguous serial chain. It allows prismatic joints in the middle of the chain and strips only terminal/tip @@ -71,7 +71,7 @@ the API normalizes them back to IDs and re-resolves current world state. # Joint-space planning for one group. manip.plan_to_joint_targets({ "left_arm/manipulator": JointState( - name=["left_arm/joint1", "left_arm/joint2"], + name=["joint1", "joint2"], position=[0.2, -0.1], ) }) @@ -87,23 +87,24 @@ manip.preview_plan(plan) manip.execute_plan(plan) ``` -For joint-space planning, start and goal joint keys must exactly match the -selected resolved joints: no missing, extra, or partial joints. +For robot-scoped joint-space planning, unnamed vectors are interpreted in robot +model joint order. If names are provided, they must be local model joint names: +no global names, missing joints, extra joints, or partial joint sets. ## Generated plans and execution A `GeneratedPlan` stores: - selected planning group IDs; -- a single synchronized path of `JointState` waypoints keyed by resolved joint +- a single synchronized path of `JointState` waypoints keyed by global joint names; - status, timing, path length, iteration count, and message metadata. Preview and execution project this path lazily. Preview sends projected joint -paths to the world monitor. Execution splits the path by affected coordinator -trajectory task, orders each trajectory by that task's configured joint order, -maps resolved/local names to coordinator names at the boundary, and invokes each -trajectory controller. Controllers remain planning-group agnostic. +paths to the world monitor. Execution splits the path by affected trajectory +task, orders each trajectory by the robot's configured local joint order, writes +global joint names at the coordinator boundary, and invokes each trajectory +controller. Controllers remain planning-group agnostic. Multi-task dispatch is not atomic in this change: if one trajectory task accepts and a later task rejects, DimOS reports the rejection but does not roll back the @@ -119,4 +120,4 @@ New planning logic should use model/SRDF structure and planning group base/tip links instead. Robot placement should be encoded in URDF/xacro/MJCF. `joint_names` remains -supported and should describe the controllable/coordinator joint set. +supported and should describe the ordered controllable local model joint set. diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index 5dc0cbee8e..e51251a1aa 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -135,7 +135,7 @@ visualization backend. ## Planning Groups Manipulation planning uses explicit planning group IDs such as -`arm/manipulator` and resolved joint names such as `arm/joint1`. See +`arm/manipulator` and global joint names such as `arm/joint1`. See [Planning Groups](/docs/capabilities/manipulation/planning_groups.md) for SRDF support, fallback generation, auxiliary groups, generated plans, and execution projection. From 702ad744adf020ba698b6be7168ecfb3d433f77a Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 12:33:44 -0700 Subject: [PATCH 040/110] refactor: tighten manipulation joint namespaces --- dimos/manipulation/manipulation_module.py | 171 +++++++++++++----- .../planning/planning_group_utils.py | 46 ++++- .../planning/test_planning_group_utils.py | 64 +++++++ dimos/manipulation/test_manipulation_unit.py | 51 ++++++ 4 files changed, 280 insertions(+), 52 deletions(-) create mode 100644 dimos/manipulation/planning/test_planning_group_utils.py diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 588834996c..47ea0fc0f0 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -56,6 +56,7 @@ from dimos.manipulation.planning.planning_identifiers import ( assert_global_joint_names, assert_local_joint_names, + is_global_joint_name, make_global_joint_name, make_global_joint_names, ) @@ -504,13 +505,65 @@ def _primary_pose_group_id_for_robot(self, robot_name: RobotName) -> PlanningGro self._world_monitor.world.list_planning_groups(), robot_name ) + def _current_positions_by_name( + self, robot_name: RobotName, current: JointState + ) -> tuple[dict[str, float], bool] | None: + """Index a robot current state and report whether its keys are global. + + World-monitor current state is a single-robot backend boundary. It may + be local (the normal backend form) or global during compatibility + migrations, but it must not mix namespaces. + """ + if len(current.name) != len(current.position): + logger.error( + "Current state for '%s' has %d names but %d positions", + robot_name, + len(current.name), + len(current.position), + ) + return None + if not current.name: + logger.error("Current state for '%s' has no joint names", robot_name) + return None + + global_flags = [is_global_joint_name(name) for name in current.name] + if any(global_flags) and not all(global_flags): + logger.error( + "Current state for '%s' mixes global and local joint names: %s", + robot_name, + list(current.name), + ) + return None + + current_is_global = all(global_flags) + try: + if current_is_global: + assert_global_joint_names(current.name) + wrong_robot = [ + name for name in current.name if not name.startswith(f"{robot_name}/") + ] + if wrong_robot: + logger.error( + "Current state for '%s' contains joints from another robot: %s", + robot_name, + wrong_robot, + ) + return None + else: + assert_local_joint_names(current.name) + except ValueError as exc: + logger.error("Invalid current state for '%s': %s", robot_name, exc) + return None + + return dict(zip(current.name, current.position, strict=True)), current_is_global + def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> JointState | None: """Collect current state for exactly the selected global joints.""" assert self._world_monitor is not None resolved_groups = self._world_monitor.world.resolve_planning_groups(group_ids) names: list[str] = [] positions: list[float] = [] - current_by_robot: dict[WorldRobotID, dict[str, float]] = {} + current_by_robot: dict[WorldRobotID, tuple[dict[str, float], bool]] = {} for group in resolved_groups: if group.robot_id not in current_by_robot: @@ -518,26 +571,48 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint if current is None: logger.error("No joint state for robot '%s'", group.robot_name) return None - current_by_robot[group.robot_id] = dict( - zip(current.name, current.position, strict=False) - ) + indexed_current = self._current_positions_by_name(group.robot_name, current) + if indexed_current is None: + return None + current_by_robot[group.robot_id] = indexed_current - robot_state = current_by_robot[group.robot_id] + robot_state, current_is_global = current_by_robot[group.robot_id] for resolved_name, local_name in zip( group.joint_names, group.local_joint_names, strict=True ): - if resolved_name in robot_state: - position = robot_state[resolved_name] - elif local_name in robot_state: - position = robot_state[local_name] - else: + lookup_name = resolved_name if current_is_global else local_name + if lookup_name not in robot_state: logger.error("Current state missing selected joint '%s'", resolved_name) return None + position = robot_state[lookup_name] names.append(resolved_name) positions.append(position) return JointState(name=names, position=positions) + def _validate_generated_plan_path( + self, group_ids: tuple[PlanningGroupID, ...], path: JointPath + ) -> None: + """Validate canonical generated plans use selected global names in group order.""" + assert self._world_monitor is not None + resolved_groups = self._world_monitor.world.resolve_planning_groups(group_ids) + expected_names = [ + joint_name for group in resolved_groups for joint_name in group.joint_names + ] + assert_global_joint_names(expected_names) + for index, waypoint in enumerate(path): + if len(waypoint.name) != len(waypoint.position): + raise ValueError( + f"Waypoint {index} has {len(waypoint.name)} names but " + f"{len(waypoint.position)} positions" + ) + assert_global_joint_names(waypoint.name) + if list(waypoint.name) != expected_names: + raise ValueError( + f"Waypoint {index} joint names {list(waypoint.name)} do not match " + f"selected planning joints {expected_names}" + ) + def _normalize_joint_target( self, group_id: PlanningGroupID, target: JointState ) -> JointState | None: @@ -565,21 +640,41 @@ def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotNam if self._world_monitor is not None: current = self._world_monitor.get_current_joint_state(robot_id) if current is not None: - current_by_name = dict(zip(current.name, current.position, strict=False)) + indexed_current = self._current_positions_by_name(robot_name, current) + if indexed_current is None: + return [] + current_by_name, current_is_global = indexed_current + else: + current_is_global = False + else: + current_is_global = False projected: JointPath = [] for waypoint in plan.path: - position_by_name = dict(zip(waypoint.name, waypoint.position, strict=False)) + if len(waypoint.name) != len(waypoint.position): + logger.error( + "Cannot project plan for '%s': waypoint has %d names but %d positions", + robot_name, + len(waypoint.name), + len(waypoint.position), + ) + return [] + try: + assert_global_joint_names(waypoint.name) + except ValueError as exc: + logger.error("Cannot project plan for '%s': %s", robot_name, exc) + return [] + position_by_name = dict(zip(waypoint.name, waypoint.position, strict=True)) positions: list[float] = [] for local_name, global_name in zip( config.joint_names, global_joint_names, strict=False ): if global_name in position_by_name: positions.append(position_by_name[global_name]) - elif global_name in current_by_name: - positions.append(current_by_name[global_name]) - elif local_name in current_by_name: - positions.append(current_by_name[local_name]) else: + current_lookup_name = global_name if current_is_global else local_name + if current_lookup_name in current_by_name: + positions.append(current_by_name[current_lookup_name]) + continue logger.error( "Cannot project plan for '%s': missing joint '%s'", robot_name, @@ -657,6 +752,10 @@ def _plan_selected_path( ) if not result.is_success(): return self._fail(f"Planning failed: {result.status.name}") + try: + self._validate_generated_plan_path(group_ids, result.path) + except ValueError as exc: + return self._fail(f"Planner returned invalid global plan: {exc}") logger.info("Path: %d waypoints", len(result.path)) self._store_generated_plan(group_ids, result) @@ -775,44 +874,30 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: pose: Target end-effector pose robot_name: Robot to plan for (required if multiple robots configured) """ - if self._kinematics is None or (r := self._begin_planning(robot_name)) is None: + if self._kinematics is None or self._world_monitor is None: + return False + robot = self._get_robot(robot_name) + if robot is None: + return False + + selected_robot_name, robot_id, _, _ = robot + group_id = self._default_group_id_for_robot(selected_robot_name) + if group_id is not None: + return self.plan_to_poses({group_id: pose}) + + if self._begin_planning(selected_robot_name) is None: return False - robot_name, robot_id = r - assert self._world_monitor # guaranteed by _begin_planning current = self._world_monitor.get_current_joint_state(robot_id) if current is None: return self._fail("No joint state") - target_pose = PoseStamped( - frame_id="world", - position=pose.position, - orientation=pose.orientation, - ) - - group_id = self._default_group_id_for_robot(robot_name) - if group_id is not None: - ik = self._kinematics.solve_pose_targets( - world=self._world_monitor.world, - pose_targets={group_id: target_pose}, - seed=current, - check_collision=True, - ) - if not ik.is_success() or ik.joint_state is None: - return self._fail(f"IK failed: {ik.status.name}") - - start = self._selected_joint_state((group_id,)) - if start is None: - return self._fail("No joint state") - logger.info(f"IK solved, error: {ik.position_error:.4f}m") - return self._plan_selected_path((group_id,), start, ik.joint_state) - ik = self._solve_ik_for_pose(robot_id, pose, current, check_collision=True) if not ik.is_success() or ik.joint_state is None: return self._fail(f"IK failed: {ik.status.name}") logger.info(f"IK solved, error: {ik.position_error:.4f}m") - return self._plan_path_only(robot_name, robot_id, ik.joint_state) + return self._plan_path_only(selected_robot_name, robot_id, ik.joint_state) @rpc def plan_to_poses( diff --git a/dimos/manipulation/planning/planning_group_utils.py b/dimos/manipulation/planning/planning_group_utils.py index 33cdf020b7..d3cbfe6a02 100644 --- a/dimos/manipulation/planning/planning_group_utils.py +++ b/dimos/manipulation/planning/planning_group_utils.py @@ -16,7 +16,11 @@ from collections.abc import Mapping, Sequence -from dimos.manipulation.planning.planning_identifiers import assert_local_joint_names +from dimos.manipulation.planning.planning_identifiers import ( + assert_global_joint_names, + assert_local_joint_names, + is_global_joint_name, +) from dimos.manipulation.planning.spec.models import ( GlobalJointName, LocalModelJointName, @@ -110,7 +114,12 @@ def normalize_joint_target_for_group( group: ResolvedPlanningGroup, target: JointState, ) -> JointState: - """Normalize a robot-scoped group target to global joint names in group order.""" + """Normalize a group joint target to global joint names in group order. + + Named targets may use either the public global planning names or the + robot-local model names used by legacy robot-scoped callers, but the two + namespaces must not be mixed in one target. + """ if not target.name: if len(target.position) != len(group.joint_names): raise ValueError( @@ -119,19 +128,38 @@ def normalize_joint_target_for_group( ) return JointState(name=list(group.joint_names), position=list(target.position)) - assert_local_joint_names(target.name) - positions_by_name = dict(zip(target.name, target.position, strict=False)) + if len(target.name) != len(target.position): + raise ValueError( + f"Target for '{group.id}' has {len(target.name)} names but " + f"{len(target.position)} positions" + ) + + target_names = list(target.name) + global_flags = [is_global_joint_name(name) for name in target_names] + if any(global_flags) and not all(global_flags): + raise ValueError( + f"Target for '{group.id}' mixes global and local joint names: {target_names}" + ) + + if all(global_flags): + assert_global_joint_names(target_names) + expected_names = group.joint_names + else: + assert_local_joint_names(target_names) + expected_names = group.local_joint_names + + positions_by_name = dict(zip(target_names, target.position, strict=True)) global_positions: list[float] = [] missing: list[str] = [] - for local_name in group.local_joint_names: - if local_name in positions_by_name: - global_positions.append(positions_by_name[local_name]) + for expected_name in expected_names: + if expected_name in positions_by_name: + global_positions.append(positions_by_name[expected_name]) else: - missing.append(local_name) + missing.append(expected_name) if missing: raise ValueError(f"Target for '{group.id}' is missing joints: {missing}") - extra = set(target.name) - set(group.local_joint_names) + extra = set(target_names) - set(expected_names) if extra: raise ValueError(f"Target for '{group.id}' has extra joints: {sorted(extra)}") return JointState(name=list(group.joint_names), position=global_positions) diff --git a/dimos/manipulation/planning/test_planning_group_utils.py b/dimos/manipulation/planning/test_planning_group_utils.py new file mode 100644 index 0000000000..fa25dac217 --- /dev/null +++ b/dimos/manipulation/planning/test_planning_group_utils.py @@ -0,0 +1,64 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for planning group joint-name normalization.""" + +from __future__ import annotations + +import pytest + +from dimos.manipulation.planning.planning_group_utils import normalize_joint_target_for_group +from dimos.manipulation.planning.spec.models import ResolvedPlanningGroup +from dimos.msgs.sensor_msgs.JointState import JointState + + +def _make_group() -> ResolvedPlanningGroup: + return ResolvedPlanningGroup( + id="left/arm", + robot_id="robot_left", + robot_name="left", + group_name="arm", + joint_names=("left/j1", "left/j2", "left/j3"), + local_joint_names=("j1", "j2", "j3"), + base_link="base", + tip_link="ee", + ) + + +def test_normalize_joint_target_accepts_named_global_targets_in_group_order() -> None: + group = _make_group() + target = JointState(name=["left/j3", "left/j1", "left/j2"], position=[3.0, 1.0, 2.0]) + + normalized = normalize_joint_target_for_group(group, target) + + assert normalized.name == ["left/j1", "left/j2", "left/j3"] + assert normalized.position == [1.0, 2.0, 3.0] + + +def test_normalize_joint_target_accepts_named_local_targets_in_group_order() -> None: + group = _make_group() + target = JointState(name=["j2", "j3", "j1"], position=[2.0, 3.0, 1.0]) + + normalized = normalize_joint_target_for_group(group, target) + + assert normalized.name == ["left/j1", "left/j2", "left/j3"] + assert normalized.position == [1.0, 2.0, 3.0] + + +def test_normalize_joint_target_rejects_mixed_global_and_local_target_names() -> None: + group = _make_group() + target = JointState(name=["left/j1", "j2", "left/j3"], position=[1.0, 2.0, 3.0]) + + with pytest.raises(ValueError, match="mixes global and local joint names"): + normalize_joint_target_for_group(group, target) diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index a1f279d8be..17cf991917 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -756,6 +756,18 @@ def test_selected_joint_state_accepts_local_current_state_names(self): assert selected.name == ["left/j1", "left/j2"] assert selected.position == [1.0, 2.0] + def test_selected_joint_state_rejects_mixed_current_state_names(self): + config = _make_robot_config("left", ["j1", "j2"], "task") + module = _make_module_with_monitor(config) + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_global_group("left", "arm", ["j1", "j2"]) + ] + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["left/j1", "j2"], position=[1.0, 2.0] + ) + + assert module._selected_joint_state(("left/arm",)) is None + def test_execute_plan_dispatches_one_trajectory_per_affected_robot(self): left_config = _make_robot_config( "left", @@ -816,6 +828,45 @@ def test_project_plan_holds_non_selected_joints_from_current_state(self): assert [state.name for state in projected] == [["j1", "j2", "j3"], ["j1", "j2", "j3"]] assert [state.position for state in projected] == [[10.0, 2.0, 30.0], [10.0, 3.0, 30.0]] + def test_project_plan_rejects_local_waypoint_names(self): + config = _make_robot_config("left", ["j1", "j2"], "task") + module = _make_module_with_monitor(config) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1", "j2"], position=[10.0, 20.0] + ) + plan = GeneratedPlan( + group_ids=("left/arm",), + path=[JointState(name=["j1"], position=[1.0])], + status=PlanningStatus.SUCCESS, + ) + + assert module._project_plan_path_for_robot(plan, "left") == [] + + def test_project_plan_uses_global_names_for_robots_with_shared_local_names(self): + left_config = _make_robot_config("left", ["j1", "j2"], "left_task") + right_config = _make_robot_config("right", ["j1", "j2"], "right_task") + module = _make_module_with_monitor(left_config, right_config) + module._world_monitor.get_current_joint_state.side_effect = [ + JointState(name=["j1", "j2"], position=[10.0, 20.0]), + JointState(name=["j1", "j2"], position=[30.0, 40.0]), + ] + plan = GeneratedPlan( + group_ids=("left/arm", "right/arm"), + path=[ + JointState(name=["left/j1", "right/j1"], position=[1.0, 3.0]), + JointState(name=["left/j1", "right/j1"], position=[2.0, 4.0]), + ], + status=PlanningStatus.SUCCESS, + ) + + left_projected = module._project_plan_path_for_robot(plan, "left") + right_projected = module._project_plan_path_for_robot(plan, "right") + + assert [state.name for state in left_projected] == [["j1", "j2"], ["j1", "j2"]] + assert [state.position for state in left_projected] == [[1.0, 20.0], [2.0, 20.0]] + assert [state.name for state in right_projected] == [["j1", "j2"], ["j1", "j2"]] + assert [state.position for state in right_projected] == [[3.0, 40.0], [4.0, 40.0]] + def test_preview_path_with_last_plan_projects_lazily_to_world_monitor(self): config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) From d370174b80a04a706ccac17a598c896d0c51b7c1 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 14:10:17 -0700 Subject: [PATCH 041/110] refactor: align manipulation planning group APIs --- CONTEXT.md | 8 + dimos/manipulation/manipulation_module.py | 385 ++++-------------- .../planning/monitor/world_monitor.py | 26 +- .../planning/planners/rrt_planner.py | 67 ++- .../planners/test_rrt_planner_selection.py | 35 ++ .../planning/planning_group_utils.py | 4 +- dimos/manipulation/planning/spec/config.py | 11 + dimos/manipulation/planning/spec/protocols.py | 15 +- .../planning/test_planning_group_utils.py | 8 +- .../planning/world/drake_world.py | 86 +++- dimos/manipulation/test_manipulation_unit.py | 220 ++++++---- 11 files changed, 430 insertions(+), 435 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index a5af10041e..701648bbed 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,6 +48,10 @@ _Avoid_: Batch planning, independent planning An API-level identifier for a planning group, always written as `{robot_name}/{group_name}`. `/` is reserved as the delimiter and is not part of either component. _Avoid_: Bare group name, robot ID +**Default Planning Group**: +The generated fallback planning group used by robot-scoped compatibility APIs when no planning group is specified explicitly. It is not inferred from arbitrary SRDF group uniqueness. +_Avoid_: Unique planning group, primary planning group + **Planning Group Descriptor**: A read-only snapshot returned by query APIs that describes an available planning group and may be used ergonomically as a planning group selector. _Avoid_: Live planning group handle, resolved planning group @@ -72,6 +76,10 @@ _Avoid_: Namespaced local joint state, prefixed joint state A flat planning result that may contain one or more robots. Joint states in a generated plan require names and use global joint names so the plan remains unambiguous across robot boundaries. _Avoid_: Robot-scoped joint plan, local joint plan +**Group-Scoped Preview**: +A visualization request for a generated plan over a planning group or planning group selection. A visualization backend may render the whole robot when partial-group rendering is not practical, but the API scope remains the generated plan's selected planning groups. +_Avoid_: Robot-scoped preview API + **Global Joint Name**: A boundary-level joint name that mechanically combines a robot name and local model joint name as `{robot_name}/{local_joint_name}` so it is stable and unique in flat joint-state representations, even when the local model joint name is already descriptive. `/` is reserved as the delimiter and is not part of either component. _Avoid_: Resolved joint name, coordinator joint name, bare joint name, local joint name diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 47ea0fc0f0..fa98f52f50 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -31,7 +31,6 @@ import traceback from typing import TYPE_CHECKING, Any, TypeAlias -import numpy as np from pydantic import Field from dimos.agents.annotation import skill @@ -48,17 +47,17 @@ ) from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor from dimos.manipulation.planning.planning_group_utils import ( - normalize_joint_target_for_group, + joint_target_to_global_names, planning_group_id_from_selector, primary_pose_planning_group_id_for_robot, - single_planning_group_id_for_robot, ) +from dimos.manipulation.planning.planning_groups import FALLBACK_PLANNING_GROUP_NAME from dimos.manipulation.planning.planning_identifiers import ( assert_global_joint_names, assert_local_joint_names, - is_global_joint_name, make_global_joint_name, make_global_joint_names, + make_planning_group_id, ) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType @@ -352,6 +351,9 @@ def _tf_publish_loop(self) -> None: for robot_id, config, _ in self._robots.values(): # Publish world → primary planning-group target frame. # Fall back to robot-scoped EE only for compatibility configs. + # TODO: Publish one TF per pose-targetable group, or expose the + # backend's full robot TF tree, once consumers stop assuming a + # single robot-scoped end-effector frame. target_frame = config.end_effector_link pose_group_id = self._primary_pose_group_id_for_robot(config.name) if pose_group_id is not None: @@ -361,7 +363,11 @@ def _tf_publish_loop(self) -> None: target_frame = pose_group.tip_link ee_pose = self._world_monitor.get_group_pose(pose_group_id) else: - ee_pose = self._world_monitor.get_ee_pose(robot_id) + ee_pose = ( + self._world_monitor.get_link_pose(robot_id, target_frame) + if target_frame is not None + else None + ) if ee_pose is not None and target_frame is not None: ee_tf = Transform.from_pose(target_frame, ee_pose) ee_tf.frame_id = "world" @@ -443,7 +449,13 @@ def get_ee_pose(self, robot_name: RobotName | None = None) -> Pose | None: robot_name: Robot to query (required if multiple robots configured) """ if (robot := self._get_robot(robot_name)) and self._world_monitor: - return self._world_monitor.get_ee_pose(robot[1], joint_state=None) + _, robot_id, config, _ = robot + pose_group_id = self._primary_pose_group_id_for_robot(config.name) + if pose_group_id is not None: + return self._world_monitor.get_group_pose(pose_group_id, joint_state=None) + if config.end_effector_link is None: + return None + return self._world_monitor.get_link_pose(robot_id, config.end_effector_link) return None @rpc @@ -488,15 +500,17 @@ def _fail(self, msg: str) -> bool: return False def _default_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: - """Return wrapper-level default group for legacy single-group RPCs.""" + """Return the generated fallback group used by robot-scoped wrappers.""" assert self._world_monitor is not None - try: - return single_planning_group_id_for_robot( - self._world_monitor.world.list_planning_groups(), robot_name - ) - except ValueError as exc: - logger.error(str(exc)) - return None + group_id = make_planning_group_id(robot_name, FALLBACK_PLANNING_GROUP_NAME) + if any(group.id == group_id for group in self._world_monitor.world.list_planning_groups()): + return group_id + logger.error( + "Robot '%s' has no generated default planning group '%s'; use explicit group APIs", + robot_name, + group_id, + ) + return None def _primary_pose_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: """Return the first pose-targetable group for robot-scoped compatibility paths.""" @@ -507,13 +521,8 @@ def _primary_pose_group_id_for_robot(self, robot_name: RobotName) -> PlanningGro def _current_positions_by_name( self, robot_name: RobotName, current: JointState - ) -> tuple[dict[str, float], bool] | None: - """Index a robot current state and report whether its keys are global. - - World-monitor current state is a single-robot backend boundary. It may - be local (the normal backend form) or global during compatibility - migrations, but it must not mix namespaces. - """ + ) -> dict[str, float] | None: + """Index a robot current state by local model joint name.""" if len(current.name) != len(current.position): logger.error( "Current state for '%s' has %d names but %d positions", @@ -525,37 +534,13 @@ def _current_positions_by_name( if not current.name: logger.error("Current state for '%s' has no joint names", robot_name) return None - - global_flags = [is_global_joint_name(name) for name in current.name] - if any(global_flags) and not all(global_flags): - logger.error( - "Current state for '%s' mixes global and local joint names: %s", - robot_name, - list(current.name), - ) - return None - - current_is_global = all(global_flags) try: - if current_is_global: - assert_global_joint_names(current.name) - wrong_robot = [ - name for name in current.name if not name.startswith(f"{robot_name}/") - ] - if wrong_robot: - logger.error( - "Current state for '%s' contains joints from another robot: %s", - robot_name, - wrong_robot, - ) - return None - else: - assert_local_joint_names(current.name) + assert_local_joint_names(current.name) except ValueError as exc: logger.error("Invalid current state for '%s': %s", robot_name, exc) return None - return dict(zip(current.name, current.position, strict=True)), current_is_global + return dict(zip(current.name, current.position, strict=True)) def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> JointState | None: """Collect current state for exactly the selected global joints.""" @@ -563,7 +548,7 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint resolved_groups = self._world_monitor.world.resolve_planning_groups(group_ids) names: list[str] = [] positions: list[float] = [] - current_by_robot: dict[WorldRobotID, tuple[dict[str, float], bool]] = {} + current_by_robot: dict[WorldRobotID, dict[str, float]] = {} for group in resolved_groups: if group.robot_id not in current_by_robot: @@ -576,51 +561,27 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint return None current_by_robot[group.robot_id] = indexed_current - robot_state, current_is_global = current_by_robot[group.robot_id] + robot_state = current_by_robot[group.robot_id] for resolved_name, local_name in zip( group.joint_names, group.local_joint_names, strict=True ): - lookup_name = resolved_name if current_is_global else local_name - if lookup_name not in robot_state: + if local_name not in robot_state: logger.error("Current state missing selected joint '%s'", resolved_name) return None - position = robot_state[lookup_name] + position = robot_state[local_name] names.append(resolved_name) positions.append(position) return JointState(name=names, position=positions) - def _validate_generated_plan_path( - self, group_ids: tuple[PlanningGroupID, ...], path: JointPath - ) -> None: - """Validate canonical generated plans use selected global names in group order.""" - assert self._world_monitor is not None - resolved_groups = self._world_monitor.world.resolve_planning_groups(group_ids) - expected_names = [ - joint_name for group in resolved_groups for joint_name in group.joint_names - ] - assert_global_joint_names(expected_names) - for index, waypoint in enumerate(path): - if len(waypoint.name) != len(waypoint.position): - raise ValueError( - f"Waypoint {index} has {len(waypoint.name)} names but " - f"{len(waypoint.position)} positions" - ) - assert_global_joint_names(waypoint.name) - if list(waypoint.name) != expected_names: - raise ValueError( - f"Waypoint {index} joint names {list(waypoint.name)} do not match " - f"selected planning joints {expected_names}" - ) - def _normalize_joint_target( self, group_id: PlanningGroupID, target: JointState ) -> JointState | None: - """Normalize a group joint target to global joint names in group order.""" + """Convert a group joint target to global joint names in group order.""" assert self._world_monitor is not None group = self._world_monitor.world.resolve_planning_groups((group_id,))[0] try: - return normalize_joint_target_for_group(group, target) + return joint_target_to_global_names(group, target) except ValueError as exc: logger.error(str(exc)) return None @@ -643,11 +604,7 @@ def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotNam indexed_current = self._current_positions_by_name(robot_name, current) if indexed_current is None: return [] - current_by_name, current_is_global = indexed_current - else: - current_is_global = False - else: - current_is_global = False + current_by_name = indexed_current projected: JointPath = [] for waypoint in plan.path: if len(waypoint.name) != len(waypoint.position): @@ -671,9 +628,8 @@ def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotNam if global_name in position_by_name: positions.append(position_by_name[global_name]) else: - current_lookup_name = global_name if current_is_global else local_name - if current_lookup_name in current_by_name: - positions.append(current_by_name[current_lookup_name]) + if local_name in current_by_name: + positions.append(current_by_name[local_name]) continue logger.error( "Cannot project plan for '%s': missing joint '%s'", @@ -689,16 +645,21 @@ def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotNam ) return projected - def _trajectory_for_robot_plan( + def _project_plan_to_robot_path_for_execution( self, plan: GeneratedPlan, robot_name: RobotName + ) -> JointPath: + """Project a generated plan to one robot's local execution path.""" + return self._project_plan_path_for_robot(plan, robot_name) + + def _trajectory_from_robot_path( + self, robot_name: RobotName, path: JointPath ) -> JointTrajectory | None: - """Generate a task-ordered trajectory for one affected robot lazily.""" - projected_path = self._project_plan_path_for_robot(plan, robot_name) - if len(projected_path) < 2: + """Generate a coordinator-facing trajectory from a local robot path.""" + if len(path) < 2: logger.error("Plan projection for '%s' has fewer than two waypoints", robot_name) return None _, config, traj_gen = self._robots[robot_name] - trajectory = traj_gen.generate([list(state.position) for state in projected_path]) + trajectory = traj_gen.generate([list(state.position) for state in path]) return JointTrajectory( joint_names=make_global_joint_names(robot_name, config.joint_names), points=trajectory.points, @@ -731,10 +692,12 @@ def _store_generated_plan( self._planned_paths.clear() self._planned_trajectories.clear() for robot_name in self._affected_robot_names(self._last_plan): - projected_path = self._project_plan_path_for_robot(self._last_plan, robot_name) + projected_path = self._project_plan_to_robot_path_for_execution( + self._last_plan, robot_name + ) if projected_path: self._planned_paths[robot_name] = projected_path - trajectory = self._trajectory_for_robot_plan(self._last_plan, robot_name) + trajectory = self._trajectory_from_robot_path(robot_name, projected_path) if trajectory is not None: self._planned_trajectories[robot_name] = trajectory @@ -752,52 +715,17 @@ def _plan_selected_path( ) if not result.is_success(): return self._fail(f"Planning failed: {result.status.name}") - try: - self._validate_generated_plan_path(group_ids, result.path) - except ValueError as exc: - return self._fail(f"Planner returned invalid global plan: {exc}") logger.info("Path: %d waypoints", len(result.path)) self._store_generated_plan(group_ids, result) self._state = ManipulationState.COMPLETED return True - def _interpolate_preview_path( - self, - planned_path: JointPath, - trajectory: JointTrajectory | None, - animation_duration: float, - target_fps: float, - ) -> JointPath: - """Densify a planned path for visualization using a timed trajectory.""" - interpolated = list(planned_path) - if trajectory is None or target_fps <= 0 or animation_duration <= 0: - return interpolated - - times = np.array([point.time_from_start for point in trajectory.points], dtype=np.float64) - positions = np.array([point.positions for point in trajectory.points], dtype=np.float64) - if len(times) <= 1 or positions.ndim != 2 or times[-1] <= times[0]: - return interpolated - - frame_count = int(np.ceil(animation_duration * target_fps)) + 1 - sample_times = np.linspace(times[0], times[-1], frame_count) - joint_names = trajectory.joint_names or planned_path[0].name - sampled_positions = np.column_stack( - [ - np.interp(sample_times, times, positions[:, joint]) - for joint in range(positions.shape[1]) - ] - ) - return [ - JointState(name=joint_names, position=position.tolist()) - for position in sampled_positions - ] - - def _dismiss_preview(self, robot_id: WorldRobotID) -> None: + def _dismiss_preview(self, group_ids: Sequence[PlanningGroupID]) -> None: """Hide the preview ghost if the world supports it.""" if self._world_monitor is None: return - self._world_monitor.hide_preview(robot_id) + self._world_monitor.hide_preview(group_ids) self._world_monitor.publish_visualization() def _solve_ik_for_pose( @@ -880,24 +808,11 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: if robot is None: return False - selected_robot_name, robot_id, _, _ = robot + selected_robot_name, _, _, _ = robot group_id = self._default_group_id_for_robot(selected_robot_name) - if group_id is not None: - return self.plan_to_poses({group_id: pose}) - - if self._begin_planning(selected_robot_name) is None: + if group_id is None: return False - - current = self._world_monitor.get_current_joint_state(robot_id) - if current is None: - return self._fail("No joint state") - - ik = self._solve_ik_for_pose(robot_id, pose, current, check_collision=True) - if not ik.is_success() or ik.joint_state is None: - return self._fail(f"IK failed: {ik.status.name}") - - logger.info(f"IK solved, error: {ik.position_error:.4f}m") - return self._plan_path_only(selected_robot_name, robot_id, ik.joint_state) + return self.plan_to_poses({group_id: pose}) @rpc def plan_to_poses( @@ -953,20 +868,15 @@ def plan_to_joints(self, joints: JointState, robot_name: RobotName | None = None joints: Target joint state (names + positions) robot_name: Robot to plan for (required if multiple robots configured) """ - if (r := self._begin_planning(robot_name)) is None: + robot = self._get_robot(robot_name) + if robot is None: return False - robot_name, robot_id = r + robot_name, _, _, _ = robot logger.info(f"Planning to joints for {robot_name}: {[f'{j:.3f}' for j in joints.position]}") group_id = self._default_group_id_for_robot(robot_name) - if group_id is not None: - goal = self._normalize_joint_target(group_id, joints) - if goal is None: - return self._fail("Invalid joint target") - start = self._selected_joint_state((group_id,)) - if start is None: - return self._fail("No joint state") - return self._plan_selected_path((group_id,), start, goal) - return self._plan_path_only(robot_name, robot_id, joints) + if group_id is None: + return False + return self.plan_to_joint_targets({group_id: joints}) @rpc def plan_to_joint_targets( @@ -1004,85 +914,26 @@ def plan_to_joint_targets( goal = JointState(name=goal_names, position=goal_positions) return self._plan_selected_path(group_ids, start, goal) - def _plan_path_only( - self, robot_name: RobotName, robot_id: WorldRobotID, goal: JointState - ) -> bool: - """Plan path from current position to goal, store result.""" - assert self._world_monitor and self._planner # guaranteed by _begin_planning - self._dismiss_preview(robot_id) - start = self._world_monitor.get_current_joint_state(robot_id) - if start is None: - return self._fail("No joint state") - - # Trim goal to planner DOF (e.g. strip gripper joint from coordinator state) - planner_dof = len(start.position) - if len(goal.position) > planner_dof: - goal = JointState( - name=list(goal.name[:planner_dof]) if goal.name else [], - position=list(goal.position[:planner_dof]), - ) - - result = self._planner.plan_joint_path( - world=self._world_monitor.world, - robot_id=robot_id, - start=start, - goal=goal, - timeout=self.config.planning_timeout, - ) - if not result.is_success(): - return self._fail(f"Planning failed: {result.status.name}") - - logger.info(f"Path: {len(result.path)} waypoints") - self._planned_paths[robot_name] = result.path - - _, _, traj_gen = self._robots[robot_name] - # Convert JointState path to list of position lists for trajectory generator - traj = traj_gen.generate([list(state.position) for state in result.path]) - self._planned_trajectories[robot_name] = traj - logger.info(f"Trajectory: {traj.duration:.3f}s") - - self._state = ManipulationState.COMPLETED - return True - @rpc def preview_plan( self, plan: GeneratedPlan | None = None, duration: float | None = None, robot_name: RobotName | None = None, - target_fps: float = 30.0, ) -> bool: """Preview a generated plan, defaulting to `_last_plan` when omitted.""" if self._world_monitor is None: return False - plan = plan or getattr(self, "_last_plan", None) + plan = plan or self._last_plan if plan is None or not plan.path: logger.warning("No generated plan to preview") return False - - robot_names = [robot_name] if robot_name is not None else self._affected_robot_names(plan) - previewed = False - for name in robot_names: - robot = self._get_robot(name) - if robot is None: - return False - resolved_name, robot_id, _, _ = robot - planned_path = self._project_plan_path_for_robot(plan, resolved_name) - if not planned_path: - logger.warning(f"No planned path to preview for {resolved_name}") - return False - trajectory = self._trajectory_for_robot_plan(plan, resolved_name) - animation_duration = ( - duration - if duration is not None - else (trajectory.duration if trajectory is not None else 3.0) - ) - interpolated = self._interpolate_preview_path( - planned_path, trajectory, animation_duration, target_fps - ) - self._world_monitor.animate_path(robot_id, interpolated, animation_duration) - previewed = True - return previewed + if robot_name is not None and robot_name not in self._affected_robot_names(plan): + logger.error("Generated plan does not affect robot '%s'", robot_name) + return False + animation_duration = duration if duration is not None else 3.0 + self._world_monitor.animate_plan(plan, animation_duration) + return True @rpc def preview_path( @@ -1091,42 +942,15 @@ def preview_path( robot_name: RobotName | None = None, target_fps: float = 30.0, ) -> bool: - """Preview the planned path in the visualizer. + """Preview the last generated plan in the visualizer. Args: - duration: Total animation duration in seconds. Uses trajectory duration if None. - robot_name: Robot to preview (required if multiple robots configured) - target_fps: Nominal preview update rate. Set <= 0 to use planned waypoints directly. + duration: Total animation duration in seconds. + robot_name: Optional compatibility filter; the stored plan must affect it. + target_fps: Deprecated; generated-plan preview timing is backend-owned. """ - last_plan = getattr(self, "_last_plan", None) - if last_plan is not None and last_plan.path: - return self.preview_plan(last_plan, duration, robot_name, target_fps) - - if self._world_monitor is None: - return False - - robot = self._get_robot(robot_name) - if robot is None: - return False - robot_name, robot_id, _, _ = robot - - planned_path = self._planned_paths.get(robot_name) - if planned_path is None or len(planned_path) == 0: - logger.warning(f"No planned path to preview for {robot_name}") - return False - - if duration is None: - trajectory = self._planned_trajectories.get(robot_name) - animation_duration = trajectory.duration if trajectory is not None else 3.0 - else: - trajectory = self._planned_trajectories.get(robot_name) - animation_duration = duration - - interpolated = self._interpolate_preview_path( - planned_path, trajectory, animation_duration, target_fps - ) - self._world_monitor.animate_path(robot_id, interpolated, animation_duration) - return True + _ = target_fps + return self.preview_plan(self._last_plan, duration, robot_name) @rpc def has_planned_path(self) -> bool: @@ -1135,16 +959,7 @@ def has_planned_path(self) -> bool: Returns: True if a path is planned and ready """ - last_plan = getattr(self, "_last_plan", None) - if last_plan is not None: - return bool(last_plan.path) - - robot = self._get_robot() - if robot is None: - return False - robot_name, _, _, _ = robot - path = self._planned_paths.get(robot_name) - return path is not None and len(path) > 0 + return self._last_plan is not None and bool(self._last_plan.path) @rpc def get_visualization_url(self) -> str | None: @@ -1332,43 +1147,14 @@ def _get_coordinator_client(self) -> RPCClient | None: @rpc def execute(self, robot_name: RobotName | None = None) -> bool: """Execute planned trajectory via ControlCoordinator.""" - last_plan = getattr(self, "_last_plan", None) - if last_plan is not None and last_plan.path: - return self.execute_plan(last_plan, robot_name) - - if (robot := self._get_robot(robot_name)) is None: - return False - robot_name, _, config, _ = robot - - if (traj := self._planned_trajectories.get(robot_name)) is None: - logger.warning("No planned trajectory") - return False - if not config.coordinator_task_name: - logger.error(f"No coordinator_task_name for '{robot_name}'") - return False - if (client := self._get_coordinator_client()) is None: - logger.error("No coordinator client") - return False - - logger.info( - f"Executing: task='{config.coordinator_task_name}', {len(traj.points)} pts, {traj.duration:.2f}s" - ) - - self._state = ManipulationState.EXECUTING - result = client.task_invoke(config.coordinator_task_name, "execute", {"trajectory": traj}) - if result: - logger.info("Trajectory accepted") - self._state = ManipulationState.COMPLETED - return True - else: - return self._fail("Coordinator rejected trajectory") + return self.execute_plan(self._last_plan, robot_name) @rpc def execute_plan( self, plan: GeneratedPlan | None = None, robot_name: RobotName | None = None ) -> bool: """Project and execute a generated plan through affected trajectory tasks.""" - plan = plan or getattr(self, "_last_plan", None) + plan = plan or self._last_plan if plan is None or not plan.path: logger.warning("No generated plan") return False @@ -1394,7 +1180,8 @@ def execute_plan( if not config.coordinator_task_name: logger.error(f"No coordinator_task_name for '{resolved_name}'") return False - trajectory = self._trajectory_for_robot_plan(plan, resolved_name) + projected_path = self._project_plan_to_robot_path_for_execution(plan, resolved_name) + trajectory = self._trajectory_from_robot_path(resolved_name, projected_path) if trajectory is None: return False dispatches.append((resolved_name, config, trajectory)) @@ -1421,7 +1208,7 @@ def execute_plan( @rpc def get_trajectory_status(self, robot_name: RobotName | None = None) -> dict[str, Any] | None: """Get trajectory execution status via coordinator task_invoke.""" - last_plan = getattr(self, "_last_plan", None) + last_plan = self._last_plan if robot_name is None and last_plan is not None and last_plan.path: statuses = { name: self.get_trajectory_status(name) @@ -1583,7 +1370,7 @@ def _wait_for_trajectory_completion( Returns: True if trajectory completed successfully """ - last_plan = getattr(self, "_last_plan", None) + last_plan = self._last_plan if robot_name is None and last_plan is not None and last_plan.path: try: robot_names = self._affected_robot_names(last_plan) diff --git a/dimos/manipulation/planning/monitor/world_monitor.py b/dimos/manipulation/planning/monitor/world_monitor.py index cb565b2b3f..80e24f2bef 100644 --- a/dimos/manipulation/planning/monitor/world_monitor.py +++ b/dimos/manipulation/planning/monitor/world_monitor.py @@ -31,7 +31,7 @@ from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Sequence import numpy as np from numpy.typing import NDArray @@ -39,6 +39,7 @@ from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.models import ( CollisionObjectMessage, + GeneratedPlan, JointPath, Obstacle, PlanningGroupID, @@ -459,25 +460,20 @@ def publish_visualization(self) -> None: if self._visualization is not None: self._visualization.publish_visualization() - def show_preview(self, robot_id: WorldRobotID) -> None: - """Show the preview representation for a robot if visualization is available.""" + def show_preview(self, group_ids: Sequence[PlanningGroupID]) -> None: + """Show preview representation for planning groups if visualization is available.""" if self._visualization is not None: - self._visualization.show_preview(robot_id) + self._visualization.show_preview(group_ids) - def hide_preview(self, robot_id: WorldRobotID) -> None: - """Hide the preview representation for a robot if visualization is available.""" + def hide_preview(self, group_ids: Sequence[PlanningGroupID]) -> None: + """Hide preview representation for planning groups if visualization is available.""" if self._visualization is not None: - self._visualization.hide_preview(robot_id) + self._visualization.hide_preview(group_ids) - def animate_path( - self, - robot_id: WorldRobotID, - path: JointPath, - duration: float = 3.0, - ) -> None: - """Animate a path if visualization is available.""" + def animate_plan(self, plan: GeneratedPlan, duration: float = 3.0) -> None: + """Animate a generated plan if visualization is available.""" if self._visualization is not None: - self._visualization.animate_path(robot_id, path, duration) + self._visualization.animate_plan(plan, duration) def start_visualization_thread(self, rate_hz: float = 10.0) -> None: """Start background thread for visualization updates at given rate.""" diff --git a/dimos/manipulation/planning/planners/rrt_planner.py b/dimos/manipulation/planning/planners/rrt_planner.py index 641dc8d62c..6fb00f75b9 100644 --- a/dimos/manipulation/planning/planners/rrt_planner.py +++ b/dimos/manipulation/planning/planners/rrt_planner.py @@ -210,13 +210,36 @@ def plan_selected_joint_path( "the robot controllable joint set exactly", ) - return self.plan_joint_path( + local_start = _global_joint_state_to_local( + start, + robot_config.name, + list(robot_config.joint_names), + selected_joint_names, + ) + local_goal = _global_joint_state_to_local( + goal, + robot_config.name, + list(robot_config.joint_names), + selected_joint_names, + ) + result = self.plan_joint_path( world=world, robot_id=robot_id, - start=_order_joint_state(start, selected_joint_names), - goal=_order_joint_state(goal, selected_joint_names), + start=local_start, + goal=local_goal, timeout=timeout, ) + if not result.is_success(): + return result + return PlanningResult( + status=result.status, + path=_local_path_to_global(result.path, robot_config.name, selected_joint_names), + planning_time=result.planning_time, + path_length=result.path_length, + iterations=result.iterations, + message=result.message, + timestamps=result.timestamps, + ) def _plan_multi_robot_selected_joint_path( self, @@ -678,6 +701,44 @@ def _robot_joint_state_from_combined( ) +def _global_joint_state_to_local( + joint_state: JointState, + robot_name: str, + robot_joint_names: list[str], + global_joint_names: list[str], +) -> JointState: + position_by_name = dict(zip(joint_state.name, joint_state.position, strict=True)) + local_joint_names = [ + local_joint_name_from_global(robot_name, name) for name in global_joint_names + ] + if local_joint_names != robot_joint_names: + raise ValueError("Global selected joints do not match robot joint order") + return JointState( + name=robot_joint_names, + position=[position_by_name[global_name] for global_name in global_joint_names], + ) + + +def _local_path_to_global( + path: JointPath, + robot_name: str, + global_joint_names: list[str], +) -> JointPath: + local_joint_names = [ + local_joint_name_from_global(robot_name, name) for name in global_joint_names + ] + global_path: JointPath = [] + for waypoint in path: + position_by_name = dict(zip(waypoint.name, waypoint.position, strict=True)) + global_path.append( + JointState( + name=global_joint_names, + position=[position_by_name[local_name] for local_name in local_joint_names], + ) + ) + return global_path + + def _coupled_config_collision_free( world: WorldSpec, robot_order: list[WorldRobotID], diff --git a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py index 5262916580..3c91c719dd 100644 --- a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py +++ b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py @@ -81,6 +81,8 @@ def __init__( self._robot_configs = robot_configs self._coupled_collision_predicate = coupled_collision_predicate self.coupled_collision_checks = 0 + self.config_collision_names: list[list[str]] = [] + self.edge_collision_names: list[tuple[list[str], list[str]]] = [] def resolve_planning_groups( self, group_ids: tuple[str, ...] @@ -94,6 +96,7 @@ def get_robot_ids(self) -> list[str]: return list(self._robot_configs) def check_config_collision_free(self, robot_id: str, joint_state: JointState) -> bool: + self.config_collision_names.append(list(joint_state.name)) return True def get_joint_limits(self, robot_id: str) -> tuple[np.ndarray, np.ndarray]: @@ -107,6 +110,7 @@ def check_edge_collision_free( goal: JointState, step_size: float, ) -> bool: + self.edge_collision_names.append((list(start.name), list(goal.name))) return True def scratch_context(self) -> nullcontext[dict[str, JointState]]: @@ -188,6 +192,37 @@ def test_plan_selected_joint_path_plans_cross_robot_full_group_selection() -> No assert world.coupled_collision_checks > 0 +def test_plan_selected_joint_path_converts_single_robot_backend_boundary_to_local() -> None: + world = _SelectionWorld( + groups={ + "arm/manipulator": _group( + "arm/manipulator", + "robot_1", + "arm", + ("arm/joint1", "arm/joint2"), + ) + }, + robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}, + ) + + result = RRTConnectPlanner().plan_selected_joint_path( + cast("WorldSpec", world), + ["arm/manipulator"], + start=_joint_state(["arm/joint2", "arm/joint1"], [0.2, 0.1]), + goal=_joint_state(["arm/joint1", "arm/joint2"], [0.3, 0.4]), + ) + + assert result.status == PlanningStatus.SUCCESS + assert [waypoint.name for waypoint in result.path] == [ + ["arm/joint1", "arm/joint2"], + ["arm/joint1", "arm/joint2"], + ] + assert result.path[0].position == [0.1, 0.2] + assert result.path[-1].position == [0.3, 0.4] + assert world.config_collision_names == [["joint1", "joint2"], ["joint1", "joint2"]] + assert world.edge_collision_names == [(["joint1", "joint2"], ["joint1", "joint2"])] + + def test_plan_selected_joint_path_rejects_cross_robot_coupled_goal_collision() -> None: def coupled_free(ctx: dict[str, JointState]) -> bool: if {"left_robot", "right_robot"} - set(ctx): diff --git a/dimos/manipulation/planning/planning_group_utils.py b/dimos/manipulation/planning/planning_group_utils.py index d3cbfe6a02..34c1874177 100644 --- a/dimos/manipulation/planning/planning_group_utils.py +++ b/dimos/manipulation/planning/planning_group_utils.py @@ -110,11 +110,11 @@ def filter_joint_state_to_selected_joints( return JointState({"name": list(global_joint_names), "position": selected_positions}) -def normalize_joint_target_for_group( +def joint_target_to_global_names( group: ResolvedPlanningGroup, target: JointState, ) -> JointState: - """Normalize a group joint target to global joint names in group order. + """Convert a group joint target to global joint names in group order. Named targets may use either the public global planning names or the robot-local model names used by legacy robot-scoped callers, but the two diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index 1365e1c2bc..18209cad24 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -21,6 +21,7 @@ from pydantic import Field from dimos.core.module import ModuleConfig +from dimos.manipulation.planning.planning_groups import FALLBACK_PLANNING_GROUP_NAME from dimos.manipulation.planning.planning_identifiers import ( assert_local_joint_names, assert_valid_robot_name, @@ -93,3 +94,13 @@ def model_post_init(self, __context: object) -> None: """Validate delimiter-based naming constraints.""" assert_valid_robot_name(self.name) assert_local_joint_names(self.joint_names) + if not self.planning_groups: + self.planning_groups = [ + PlanningGroupDefinition( + name=FALLBACK_PLANNING_GROUP_NAME, + joint_names=tuple(self.joint_names), + base_link=self.base_link, + tip_link=self.end_effector_link, + source="fallback", + ) + ] diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index 851bc5a2e7..bb761088a4 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable if TYPE_CHECKING: + from collections.abc import Sequence from contextlib import AbstractContextManager import numpy as np @@ -30,8 +31,8 @@ from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.models import ( + GeneratedPlan, IKResult, - JointPath, Obstacle, PlanningGroupDescriptor, PlanningGroupID, @@ -220,16 +221,16 @@ def publish_visualization(self, ctx: Any | None = None) -> None: """Publish current state to visualization.""" ... - def show_preview(self, robot_id: WorldRobotID) -> None: - """Show the preview representation for a robot.""" + def show_preview(self, group_ids: Sequence[PlanningGroupID]) -> None: + """Show preview representations for the selected planning groups.""" ... - def hide_preview(self, robot_id: WorldRobotID) -> None: - """Hide the preview representation for a robot.""" + def hide_preview(self, group_ids: Sequence[PlanningGroupID]) -> None: + """Hide preview representations for the selected planning groups.""" ... - def animate_path(self, robot_id: WorldRobotID, path: JointPath, duration: float = 3.0) -> None: - """Animate a path in visualization.""" + def animate_plan(self, plan: GeneratedPlan, duration: float = 3.0) -> None: + """Animate a generated plan in visualization.""" ... def close(self) -> None: diff --git a/dimos/manipulation/planning/test_planning_group_utils.py b/dimos/manipulation/planning/test_planning_group_utils.py index fa25dac217..826dd2eab7 100644 --- a/dimos/manipulation/planning/test_planning_group_utils.py +++ b/dimos/manipulation/planning/test_planning_group_utils.py @@ -18,7 +18,7 @@ import pytest -from dimos.manipulation.planning.planning_group_utils import normalize_joint_target_for_group +from dimos.manipulation.planning.planning_group_utils import joint_target_to_global_names from dimos.manipulation.planning.spec.models import ResolvedPlanningGroup from dimos.msgs.sensor_msgs.JointState import JointState @@ -40,7 +40,7 @@ def test_normalize_joint_target_accepts_named_global_targets_in_group_order() -> group = _make_group() target = JointState(name=["left/j3", "left/j1", "left/j2"], position=[3.0, 1.0, 2.0]) - normalized = normalize_joint_target_for_group(group, target) + normalized = joint_target_to_global_names(group, target) assert normalized.name == ["left/j1", "left/j2", "left/j3"] assert normalized.position == [1.0, 2.0, 3.0] @@ -50,7 +50,7 @@ def test_normalize_joint_target_accepts_named_local_targets_in_group_order() -> group = _make_group() target = JointState(name=["j2", "j3", "j1"], position=[2.0, 3.0, 1.0]) - normalized = normalize_joint_target_for_group(group, target) + normalized = joint_target_to_global_names(group, target) assert normalized.name == ["left/j1", "left/j2", "left/j3"] assert normalized.position == [1.0, 2.0, 3.0] @@ -61,4 +61,4 @@ def test_normalize_joint_target_rejects_mixed_global_and_local_target_names() -> target = JointState(name=["left/j1", "j2", "left/j3"], position=[1.0, 2.0, 3.0]) with pytest.raises(ValueError, match="mixes global and local joint names"): - normalize_joint_target_for_group(group, target) + joint_target_to_global_names(group, target) diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 666672b048..2d39f37e71 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -35,7 +35,7 @@ from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import ObstacleType from dimos.manipulation.planning.spec.models import ( - JointPath, + GeneratedPlan, Obstacle, PlanningGroupDescriptor, PlanningGroupID, @@ -47,7 +47,7 @@ from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Sequence from numpy.typing import NDArray @@ -803,7 +803,7 @@ def finalize(self) -> None: self.publish_visualization() # Hide all preview robots initially for robot_id in self._robots: - self.hide_preview(robot_id) + self._hide_preview_robot(robot_id) @property def is_finalized(self) -> bool: @@ -1233,8 +1233,18 @@ def _set_preview_positions( full_positions[idx] = positions[i] self._plant.SetPositions(plant_ctx, full_positions) - def show_preview(self, robot_id: WorldRobotID) -> None: - """Show the preview (yellow ghost) robot in Meshcat.""" + def _preview_robot_ids_for_groups( + self, group_ids: Sequence[PlanningGroupID] + ) -> list[WorldRobotID]: + """Resolve planning groups to stable preview robot IDs.""" + robot_ids: list[WorldRobotID] = [] + for group in self.resolve_planning_groups(tuple(group_ids)): + if group.robot_id not in robot_ids: + robot_ids.append(group.robot_id) + return robot_ids + + def _show_preview_robot(self, robot_id: WorldRobotID) -> None: + """Show one preview (yellow ghost) robot in Meshcat.""" if self._meshcat is None: return robot_data = self._robots.get(robot_id) @@ -1243,8 +1253,13 @@ def show_preview(self, robot_id: WorldRobotID) -> None: model_name = self._plant.GetModelInstanceName(robot_data.preview_model_instance) self._meshcat.SetProperty(f"visualizer/{model_name}", "visible", True) - def hide_preview(self, robot_id: WorldRobotID) -> None: - """Hide the preview (yellow ghost) robot in Meshcat.""" + def show_preview(self, group_ids: Sequence[PlanningGroupID]) -> None: + """Show preview robots affected by planning groups.""" + for robot_id in self._preview_robot_ids_for_groups(group_ids): + self._show_preview_robot(robot_id) + + def _hide_preview_robot(self, robot_id: WorldRobotID) -> None: + """Hide one preview (yellow ghost) robot in Meshcat.""" if self._meshcat is None: return robot_data = self._robots.get(robot_id) @@ -1253,32 +1268,63 @@ def hide_preview(self, robot_id: WorldRobotID) -> None: model_name = self._plant.GetModelInstanceName(robot_data.preview_model_instance) self._meshcat.SetProperty(f"visualizer/{model_name}", "visible", False) - def animate_path( + def hide_preview(self, group_ids: Sequence[PlanningGroupID]) -> None: + """Hide preview robots affected by planning groups.""" + for robot_id in self._preview_robot_ids_for_groups(group_ids): + self._hide_preview_robot(robot_id) + + def _preview_positions_for_waypoint( self, robot_id: WorldRobotID, - path: JointPath, - duration: float = 3.0, - ) -> None: - """Animate a path using the preview (yellow ghost) robot. + selected_positions_by_name: dict[str, float], + current_positions: NDArray[np.float64], + ) -> NDArray[np.float64]: + """Build full local preview positions for one robot from selected globals.""" + robot_data = self._robots[robot_id] + positions = current_positions.copy() + for index, local_name in enumerate(robot_data.config.joint_names): + global_name = make_global_joint_name(robot_data.config.name, local_name) + if global_name in selected_positions_by_name: + positions[index] = selected_positions_by_name[global_name] + return positions + + def animate_plan(self, plan: GeneratedPlan, duration: float = 3.0) -> None: + """Animate a generated plan using preview (yellow ghost) robots. The preview stays visible after animation completes. """ - if self._meshcat is None or len(path) < 2: + if self._meshcat is None or len(plan.path) < 2: return - robot_data = self._robots.get(robot_id) - if robot_data is None or robot_data.preview_model_instance is None: + robot_ids = [ + robot_id + for robot_id in self._preview_robot_ids_for_groups(plan.group_ids) + if self._robots[robot_id].preview_model_instance is not None + ] + if not robot_ids: return import time - self.show_preview(robot_id) - dt = duration / (len(path) - 1) - for joint_state in path: - positions = np.array(joint_state.position, dtype=np.float64) + self.show_preview(plan.group_ids) + dt = duration / (len(plan.path) - 1) + for joint_state in plan.path: + selected_positions_by_name = dict( + zip(joint_state.name, joint_state.position, strict=True) + ) with self._lock: assert self._plant_context is not None - self._set_preview_positions(self._plant_context, robot_id, positions) + for robot_id in robot_ids: + robot_data = self._robots[robot_id] + current_positions = self._plant.GetPositions( + self._plant_context, robot_data.model_instance + ) + positions = self._preview_positions_for_waypoint( + robot_id, + selected_positions_by_name, + np.array(current_positions, dtype=np.float64), + ) + self._set_preview_positions(self._plant_context, robot_id, positions) self.publish_visualization() time.sleep(dt) diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 17cf991917..4ea115a945 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -355,7 +355,6 @@ def test_execute_requires_trajectory(self, robot_config): def test_execute_requires_task_name(self): """Execute fails without coordinator_task_name.""" - module = _make_module() config_no_task = RobotModelConfig( name="arm", model_path=Path("/path"), @@ -363,20 +362,47 @@ def test_execute_requires_task_name(self): joint_names=["j1"], end_effector_link="ee", ) - module._robots = {"arm": ("id", config_no_task, MagicMock())} - module._planned_trajectories = {"arm": MagicMock()} + module = _make_module_with_monitor(config_no_task) + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_global_group("arm", "manipulator", ["j1"]) + ] + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1"], position=[0.0] + ) + module._last_plan = GeneratedPlan( + group_ids=("arm/manipulator",), + path=[JointState(name=["arm/j1"], position=[1.0])], + status=PlanningStatus.SUCCESS, + ) assert module.execute() is False def test_execute_success(self, robot_config, simple_trajectory): """Successful execute calls coordinator via task_invoke.""" - module = _make_module() - module._robots = {"test_arm": ("id", robot_config, MagicMock())} - global_trajectory = JointTrajectory( - joint_names=["test_arm/joint1", "test_arm/joint2", "test_arm/joint3"], - points=simple_trajectory.points, + module = _make_module_with_monitor(robot_config) + generator = MagicMock() + generator.generate.return_value = simple_trajectory + module._robots = {"test_arm": ("id", robot_config, generator)} + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_global_group("test_arm", "manipulator", ["joint1", "joint2", "joint3"]) + ] + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["joint1", "joint2", "joint3"], position=[0.0, 0.0, 0.0] + ) + module._last_plan = GeneratedPlan( + group_ids=("test_arm/manipulator",), + path=[ + JointState( + name=["test_arm/joint1", "test_arm/joint2", "test_arm/joint3"], + position=[0.0, 0.0, 0.0], + ), + JointState( + name=["test_arm/joint1", "test_arm/joint2", "test_arm/joint3"], + position=[0.5, 0.5, 0.5], + ), + ], + status=PlanningStatus.SUCCESS, ) - module._planned_trajectories = {"test_arm": global_trajectory} mock_client = MagicMock() mock_client.task_invoke.return_value = True @@ -384,15 +410,42 @@ def test_execute_success(self, robot_config, simple_trajectory): assert module.execute() is True assert module._state == ManipulationState.COMPLETED - mock_client.task_invoke.assert_called_once_with( - "traj_arm", "execute", {"trajectory": global_trajectory} - ) + mock_client.task_invoke.assert_called_once() + assert mock_client.task_invoke.call_args.args[:2] == ("traj_arm", "execute") + trajectory = mock_client.task_invoke.call_args.args[2]["trajectory"] + assert trajectory.joint_names == [ + "test_arm/joint1", + "test_arm/joint2", + "test_arm/joint3", + ] + assert trajectory.points == simple_trajectory.points def test_execute_rejected(self, robot_config, simple_trajectory): """Rejected execution sets FAULT state.""" - module = _make_module() - module._robots = {"test_arm": ("id", robot_config, MagicMock())} - module._planned_trajectories = {"test_arm": simple_trajectory} + module = _make_module_with_monitor(robot_config) + generator = MagicMock() + generator.generate.return_value = simple_trajectory + module._robots = {"test_arm": ("id", robot_config, generator)} + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_global_group("test_arm", "manipulator", ["joint1", "joint2", "joint3"]) + ] + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["joint1", "joint2", "joint3"], position=[0.0, 0.0, 0.0] + ) + module._last_plan = GeneratedPlan( + group_ids=("test_arm/manipulator",), + path=[ + JointState( + name=["test_arm/joint1", "test_arm/joint2", "test_arm/joint3"], + position=[0.0, 0.0, 0.0], + ), + JointState( + name=["test_arm/joint1", "test_arm/joint2", "test_arm/joint3"], + position=[0.5, 0.5, 0.5], + ), + ], + status=PlanningStatus.SUCCESS, + ) mock_client = MagicMock() mock_client.task_invoke.return_value = False @@ -623,11 +676,21 @@ def test_visualization_routing_and_stop_all_monitors(self): assert monitor.get_visualization_url() == "123" monitor.publish_visualization() - monitor.show_preview("robot") - monitor.hide_preview("robot") - monitor.animate_path("robot", [1, 2, 3], 4.5) + group_ids = ("robot/manipulator",) + plan = GeneratedPlan( + group_ids=group_ids, + path=[JointState(name=["robot/j1"], position=[0.0])], + status=PlanningStatus.SUCCESS, + ) + monitor.show_preview(group_ids) + monitor.hide_preview(group_ids) + monitor.animate_plan(plan, 4.5) assert monitor.visualization is viz + viz.show_preview.assert_called_once_with(group_ids) + viz.hide_preview.assert_called_once_with(group_ids) + viz.animate_plan.assert_called_once_with(plan, 4.5) + monitor.stop_all_monitors() viz.close.assert_called_once() @@ -639,9 +702,14 @@ def test_visualization_none_is_noop(self): assert monitor.get_visualization_url() is None monitor.publish_visualization() - monitor.show_preview("robot") - monitor.hide_preview("robot") - monitor.animate_path("robot", [1], 1.0) + plan = GeneratedPlan( + group_ids=("robot/manipulator",), + path=[JointState(name=["robot/j1"], position=[0.0])], + status=PlanningStatus.SUCCESS, + ) + monitor.show_preview(("robot/manipulator",)) + monitor.hide_preview(("robot/manipulator",)) + monitor.animate_plan(plan, 1.0) monitor.start_visualization_thread() assert monitor._viz_thread is None @@ -650,93 +718,83 @@ class TestManipulationPreview: def test_dismiss_preview_noop_without_monitor(self): module = _make_module() - module._dismiss_preview("robot_id") + module._dismiss_preview(("arm/manipulator",)) def test_dismiss_preview_routes_to_monitor(self): module = _make_module() module._world_monitor = MagicMock() - module._dismiss_preview("robot_id") + group_ids = ("arm/manipulator",) + module._dismiss_preview(group_ids) - module._world_monitor.hide_preview.assert_called_once_with("robot_id") + module._world_monitor.hide_preview.assert_called_once_with(group_ids) module._world_monitor.publish_visualization.assert_called_once_with() - def test_preview_path_uses_trajectory_duration_and_interpolates(self): + def test_preview_path_delegates_last_plan_with_default_duration(self): module = _make_module() module._world_monitor = MagicMock() - module._robots = {"arm": ("robot_id", MagicMock(), MagicMock())} - module._planned_paths = {"arm": _make_path([0.0], [2.0])} - module._planned_trajectories = {"arm": _make_trajectory((0.0, [0.0]), (2.0, [2.0]))} + module._last_plan = GeneratedPlan( + group_ids=("arm/manipulator",), + path=[JointState(name=["arm/j1"], position=[0.0])], + status=PlanningStatus.SUCCESS, + ) - assert module.preview_path(robot_name="arm", target_fps=2.0) is True + assert module.preview_path(target_fps=2.0) is True - module._world_monitor.animate_path.assert_called_once() - robot_id, preview_path, duration = module._world_monitor.animate_path.call_args.args - assert robot_id == "robot_id" - assert duration == 2.0 - assert [state.position for state in preview_path] == [[0.0], [0.5], [1.0], [1.5], [2.0]] + module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 3.0) - def test_preview_path_explicit_duration_overrides_and_fps_densifies(self): + def test_preview_path_explicit_duration_overrides_default(self): module = _make_module() module._world_monitor = MagicMock() - module._robots = {"arm": ("robot_id", MagicMock(), MagicMock())} - module._planned_paths = {"arm": _make_path([0.0], [9.0])} - module._planned_trajectories = {"arm": _make_trajectory((0.0, [0.0]), (9.0, [9.0]))} + module._last_plan = GeneratedPlan( + group_ids=("arm/manipulator",), + path=[JointState(name=["arm/j1"], position=[0.0])], + status=PlanningStatus.SUCCESS, + ) - assert module.preview_path(duration=1.5, robot_name="arm", target_fps=2.0) is True + assert module.preview_path(duration=1.5, target_fps=2.0) is True - module._world_monitor.animate_path.assert_called_once() - robot_id, preview_path, duration = module._world_monitor.animate_path.call_args.args - assert robot_id == "robot_id" - assert duration == 1.5 - assert [state.position for state in preview_path] == [[0.0], [3.0], [6.0], [9.0]] + module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 1.5) - def test_preview_path_missing_trajectory_uses_default_duration(self): + def test_preview_path_respects_robot_filter(self): module = _make_module() module._world_monitor = MagicMock() - module._robots = {"arm": ("robot_id", MagicMock(), MagicMock())} - module._planned_paths = {"arm": _make_path([0.0], [1.0])} - module._planned_trajectories = {} + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_global_group("arm", "manipulator", ["j1"]) + ] + module._last_plan = GeneratedPlan( + group_ids=("arm/manipulator",), + path=[JointState(name=["arm/j1"], position=[0.0])], + status=PlanningStatus.SUCCESS, + ) - assert module.preview_path(robot_name="arm", target_fps=10.0) is True + assert module.preview_path(robot_name="arm") is True - module._world_monitor.animate_path.assert_called_once_with( - "robot_id", module._planned_paths["arm"], 3.0 - ) + module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 3.0) - def test_preview_path_skips_interpolation_for_nonpositive_fps_or_duration(self): + def test_preview_path_rejects_unaffected_robot_filter(self): module = _make_module() module._world_monitor = MagicMock() - module._robots = {"arm": ("robot_id", MagicMock(), MagicMock())} - module._planned_paths = {"arm": _make_path([0.0], [1.0])} - module._planned_trajectories = {"arm": _make_trajectory((0.0, [0.0]), (2.0, [1.0]))} + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_global_group("arm", "manipulator", ["j1"]) + ] + module._last_plan = GeneratedPlan( + group_ids=("arm/manipulator",), + path=[JointState(name=["arm/j1"], position=[0.0])], + status=PlanningStatus.SUCCESS, + ) - assert module.preview_path(robot_name="arm", target_fps=0.0) is True - assert module.preview_path(duration=0.0, robot_name="arm", target_fps=20.0) is True + assert module.preview_path(robot_name="other") is False - assert ( - module._world_monitor.animate_path.call_args_list[0].args[1] - == module._planned_paths["arm"] - ) - assert ( - module._world_monitor.animate_path.call_args_list[1].args[1] - == module._planned_paths["arm"] - ) + module._world_monitor.animate_plan.assert_not_called() def test_preview_path_returns_false_for_missing_inputs(self): module = _make_module() - module._planned_paths = {"arm": _make_path([0.0], [1.0])} - module._robots = {"arm": ("robot_id", MagicMock(), MagicMock())} - assert module.preview_path(robot_name="arm") is False + assert module.preview_path() is False module._world_monitor = MagicMock() - module._robots = {} - assert module.preview_path(robot_name="arm") is False - - module._robots = {"arm": ("robot_id", MagicMock(), MagicMock())} - module._planned_paths = {"arm": []} - assert module.preview_path(robot_name="arm") is False + assert module.preview_path() is False class TestGeneratedPlanProjection: @@ -867,16 +925,12 @@ def test_project_plan_uses_global_names_for_robots_with_shared_local_names(self) assert [state.name for state in right_projected] == [["j1", "j2"], ["j1", "j2"]] assert [state.position for state in right_projected] == [[3.0, 40.0], [4.0, 40.0]] - def test_preview_path_with_last_plan_projects_lazily_to_world_monitor(self): + def test_preview_path_with_last_plan_animates_generated_plan(self): config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) - module._robots["left"] = ("robot_left", config, _trajectory_generator()) module._world_monitor.world.resolve_planning_groups.return_value = [ _make_global_group("left", "arm", ["j1"]) ] - module._world_monitor.get_current_joint_state.return_value = JointState( - name=["j1", "j2"], position=[0.0, 7.0] - ) module._last_plan = GeneratedPlan( group_ids=("left/arm",), path=[ @@ -888,11 +942,7 @@ def test_preview_path_with_last_plan_projects_lazily_to_world_monitor(self): assert module.preview_path(robot_name="left", target_fps=0.0) is True - module._world_monitor.animate_path.assert_called_once() - robot_id, path, duration = module._world_monitor.animate_path.call_args.args - assert robot_id == "robot_left" - assert duration == 1.0 - assert [state.position for state in path] == [[1.0, 7.0], [2.0, 7.0]] + module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 3.0) def test_has_and_clear_planned_path_use_last_plan(self): module = _make_module() From b3470bd9a9b3fbd58f1342944fbfd56c1896b18b Mon Sep 17 00:00:00 2001 From: cc <55869557+TomCC7@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:35:55 -0700 Subject: [PATCH 042/110] Apply suggestions from code review Co-authored-by: cc <55869557+TomCC7@users.noreply.github.com> --- dimos/manipulation/manipulation_module.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index a963b3a5d3..c9cb5eef2f 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -293,8 +293,6 @@ def _on_joint_state(self, msg: JointState) -> None: if self._world_monitor is None: return - if not msg.name: - raise ValueError("Aggregate joint states must include global joint names") assert_global_joint_names(msg.name) # Build name → index map once for the whole message From a3b8459912a3370177841ccd3eb3a8d7f0bd1601 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 15:12:59 -0700 Subject: [PATCH 043/110] docs: note pose group tf follow-up --- dimos/manipulation/planning/planning_group_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dimos/manipulation/planning/planning_group_utils.py b/dimos/manipulation/planning/planning_group_utils.py index 34c1874177..3b2db4701c 100644 --- a/dimos/manipulation/planning/planning_group_utils.py +++ b/dimos/manipulation/planning/planning_group_utils.py @@ -60,6 +60,8 @@ def primary_pose_planning_group_id_for_robot( robot_name: RobotName, ) -> PlanningGroupID | None: """Return the first pose-targetable group ID for compatibility paths.""" + # TODO: Replace this compatibility selection with either one TF publication per + # pose-targetable planning group or backend-level whole-robot TF publishing. for group in groups: if group.robot_name == robot_name and group.has_pose_target: return group.id From 1671ddba9eaf599de54c352394dc8c94743e0bbb Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 15:14:53 -0700 Subject: [PATCH 044/110] refactor: split selected joint state collection --- dimos/manipulation/manipulation_module.py | 28 +++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index c9cb5eef2f..a7c4087498 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -544,21 +544,25 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint """Collect current state for exactly the selected global joints.""" assert self._world_monitor is not None resolved_groups = self._world_monitor.world.resolve_planning_groups(group_ids) - names: list[str] = [] - positions: list[float] = [] - current_by_robot: dict[WorldRobotID, dict[str, float]] = {} + robot_names_by_id: dict[WorldRobotID, RobotName] = {} for group in resolved_groups: - if group.robot_id not in current_by_robot: - current = self._world_monitor.get_current_joint_state(group.robot_id) - if current is None: - logger.error("No joint state for robot '%s'", group.robot_name) - return None - indexed_current = self._current_positions_by_name(group.robot_name, current) - if indexed_current is None: - return None - current_by_robot[group.robot_id] = indexed_current + robot_names_by_id.setdefault(group.robot_id, group.robot_name) + + current_by_robot: dict[WorldRobotID, dict[str, float]] = {} + for robot_id, robot_name in robot_names_by_id.items(): + current = self._world_monitor.get_current_joint_state(robot_id) + if current is None: + logger.error("No joint state for robot '%s'", robot_name) + return None + indexed_current = self._current_positions_by_name(robot_name, current) + if indexed_current is None: + return None + current_by_robot[robot_id] = indexed_current + names: list[str] = [] + positions: list[float] = [] + for group in resolved_groups: robot_state = current_by_robot[group.robot_id] for resolved_name, local_name in zip( group.joint_names, group.local_joint_names, strict=True From 288fca5891748dea88a1bb8999e8a86b0fa8c035 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 15:17:10 -0700 Subject: [PATCH 045/110] refactor: begin planning by group selection --- dimos/manipulation/manipulation_module.py | 36 ++++++++------------ dimos/manipulation/test_manipulation_unit.py | 7 ++-- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index a7c4087498..b6cd6a169b 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -471,24 +471,18 @@ def is_collision_free(self, joints: list[float], robot_name: RobotName | None = return False def _begin_planning( - self, robot_name: RobotName | None = None - ) -> tuple[RobotName, WorldRobotID] | None: - """Check state and begin planning. Returns (robot_name, robot_id) or None. - - Args: - robot_name: Robot to plan for (required if multiple robots configured) - """ + self, group_ids: Sequence[PlanningGroupID] + ) -> tuple[PlanningGroupID, ...] | None: + """Check state and begin planning for the selected planning groups.""" if self._world_monitor is None: logger.error("Planning not initialized") return None - if (robot := self._get_robot(robot_name)) is None: - return None with self._lock: if self._state not in (ManipulationState.IDLE, ManipulationState.COMPLETED): logger.warning(f"Cannot plan: state is {self._state.name}") return None self._state = ManipulationState.PLANNING - return robot[0], robot[1] + return tuple(group_ids) def _fail(self, msg: str) -> bool: """Set FAULT state with error message.""" @@ -827,11 +821,6 @@ def plan_to_poses( return False if not pose_targets: return self._fail("At least one pose target is required") - with self._lock: - if self._state not in (ManipulationState.IDLE, ManipulationState.COMPLETED): - logger.warning(f"Cannot plan: state is {self._state.name}") - return False - self._state = ManipulationState.PLANNING stamped_targets = { planning_group_id_from_selector(group): PoseStamped( @@ -843,6 +832,10 @@ def plan_to_poses( } auxiliary_ids = tuple(planning_group_id_from_selector(group) for group in auxiliary_groups) group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_ids))) + planned_group_ids = self._begin_planning(group_ids) + if planned_group_ids is None: + return False + group_ids = planned_group_ids try: start = self._selected_joint_state(group_ids) @@ -889,13 +882,14 @@ def plan_to_joint_targets( return False if not joint_targets: return self._fail("At least one joint target is required") - with self._lock: - if self._state not in (ManipulationState.IDLE, ManipulationState.COMPLETED): - logger.warning(f"Cannot plan: state is {self._state.name}") - return False - self._state = ManipulationState.PLANNING - group_ids = tuple(planning_group_id_from_selector(group) for group in joint_targets) + group_ids = tuple( + dict.fromkeys(planning_group_id_from_selector(group) for group in joint_targets) + ) + planned_group_ids = self._begin_planning(group_ids) + if planned_group_ids is None: + return False + group_ids = planned_group_ids try: start = self._selected_joint_state(group_ids) except Exception as exc: diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 4ea115a945..5d615be1e6 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -155,19 +155,20 @@ def test_begin_planning_state_checks(self, robot_config): module = _make_module() module._world_monitor = MagicMock() module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} + group_ids = ("test_arm/manipulator",) # From IDLE - OK module._state = ManipulationState.IDLE - assert module._begin_planning() == ("test_arm", "robot_id") + assert module._begin_planning(group_ids) == group_ids assert module._state == ManipulationState.PLANNING # From COMPLETED - OK module._state = ManipulationState.COMPLETED - assert module._begin_planning() == ("test_arm", "robot_id") + assert module._begin_planning(group_ids) == group_ids # From EXECUTING - Fail module._state = ManipulationState.EXECUTING - assert module._begin_planning() is None + assert module._begin_planning(group_ids) is None class TestRobotSelection: From 96f3fcca02e8599209abc0120e05f2f3d5a2f6c7 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 15:18:39 -0700 Subject: [PATCH 046/110] refactor: type drake pose transform --- dimos/manipulation/planning/world/drake_world.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 2d39f37e71..19df494c5e 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -93,7 +93,7 @@ logger = setup_logger() -def _pose_stamped_from_drake_transform(transform: Any) -> PoseStamped: +def _pose_stamped_from_drake_transform(transform: RigidTransform) -> PoseStamped: """Convert a Drake RigidTransform-like object to a world-frame pose.""" position = transform.translation() quaternion = transform.rotation().ToQuaternion() From d5788b28c79a7e3a10e03b17f8f33a636d43635c Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 15:19:51 -0700 Subject: [PATCH 047/110] refactor: name joint target conversion explicitly --- dimos/manipulation/manipulation_module.py | 10 +++++----- .../manipulation/planning/test_planning_group_utils.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index b6cd6a169b..13745a6025 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -570,7 +570,7 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint return JointState(name=names, position=positions) - def _normalize_joint_target( + def _joint_target_to_global_names( self, group_id: PlanningGroupID, target: JointState ) -> JointState | None: """Convert a group joint target to global joint names in group order.""" @@ -901,11 +901,11 @@ def plan_to_joint_targets( goal_positions: list[float] = [] for group, target in joint_targets.items(): group_id = planning_group_id_from_selector(group) - normalized = self._normalize_joint_target(group_id, target) - if normalized is None: + target_global = self._joint_target_to_global_names(group_id, target) + if target_global is None: return self._fail(f"Invalid joint target for '{group_id}'") - goal_names.extend(normalized.name) - goal_positions.extend(normalized.position) + goal_names.extend(target_global.name) + goal_positions.extend(target_global.position) goal = JointState(name=goal_names, position=goal_positions) return self._plan_selected_path(group_ids, start, goal) diff --git a/dimos/manipulation/planning/test_planning_group_utils.py b/dimos/manipulation/planning/test_planning_group_utils.py index 826dd2eab7..dacfcf53a2 100644 --- a/dimos/manipulation/planning/test_planning_group_utils.py +++ b/dimos/manipulation/planning/test_planning_group_utils.py @@ -36,7 +36,7 @@ def _make_group() -> ResolvedPlanningGroup: ) -def test_normalize_joint_target_accepts_named_global_targets_in_group_order() -> None: +def test_joint_target_to_global_names_accepts_named_global_targets_in_group_order() -> None: group = _make_group() target = JointState(name=["left/j3", "left/j1", "left/j2"], position=[3.0, 1.0, 2.0]) @@ -46,7 +46,7 @@ def test_normalize_joint_target_accepts_named_global_targets_in_group_order() -> assert normalized.position == [1.0, 2.0, 3.0] -def test_normalize_joint_target_accepts_named_local_targets_in_group_order() -> None: +def test_joint_target_to_global_names_accepts_named_local_targets_in_group_order() -> None: group = _make_group() target = JointState(name=["j2", "j3", "j1"], position=[2.0, 3.0, 1.0]) @@ -56,7 +56,7 @@ def test_normalize_joint_target_accepts_named_local_targets_in_group_order() -> assert normalized.position == [1.0, 2.0, 3.0] -def test_normalize_joint_target_rejects_mixed_global_and_local_target_names() -> None: +def test_joint_target_to_global_names_rejects_mixed_global_and_local_target_names() -> None: group = _make_group() target = JointState(name=["left/j1", "j2", "left/j3"], position=[1.0, 2.0, 3.0]) From 4fcb7ef6438264600e2d32d7d906fb5bc6c3c4c9 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 15:27:42 -0700 Subject: [PATCH 048/110] refactor: execute generated plans directly --- dimos/manipulation/manipulation_module.py | 180 ++++++------------ .../manipulation/test_manipulation_module.py | 17 +- dimos/manipulation/test_manipulation_unit.py | 77 +++----- 3 files changed, 89 insertions(+), 185 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 13745a6025..c0932c7fc9 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -55,7 +55,6 @@ from dimos.manipulation.planning.planning_identifiers import ( assert_global_joint_names, assert_local_joint_names, - make_global_joint_name, make_global_joint_names, make_planning_group_id, ) @@ -64,7 +63,6 @@ from dimos.manipulation.planning.spec.models import ( GeneratedPlan, IKResult, - JointPath, Obstacle, PlanningGroupDescriptor, PlanningGroupID, @@ -98,12 +96,6 @@ RobotRegistry: TypeAlias = dict[RobotName, RobotEntry] """Maps robot_name -> RobotEntry""" -PlannedPaths: TypeAlias = dict[RobotName, JointPath] -"""Maps robot_name -> planned joint path""" - -PlannedTrajectories: TypeAlias = dict[RobotName, JointTrajectory] -"""Maps robot_name -> planned trajectory""" - class ManipulationState(Enum): """State machine for manipulation module.""" @@ -161,9 +153,7 @@ def __init__(self, **kwargs: Any) -> None: # Robot registry: maps robot_name -> (world_robot_id, config, trajectory_gen) self._robots: RobotRegistry = {} - # Stored path for plan/preview/execute workflow (per robot) - self._planned_paths: PlannedPaths = {} - self._planned_trajectories: PlannedTrajectories = {} + # Stored generated plan for preview/execute workflow. self._last_plan: GeneratedPlan | None = None # Coordinator integration (lazy initialized) @@ -582,86 +572,6 @@ def _joint_target_to_global_names( logger.error(str(exc)) return None - def _project_plan_path_for_robot(self, plan: GeneratedPlan, robot_name: RobotName) -> JointPath: - """Project combined plan path to one robot in configured local joint order. - - Generated plans only contain selected global joints. Trajectory tasks may - still be configured for the robot's full controllable joint set, so - non-selected joints are held at their current positions during projection. - """ - robot_id, config, _ = self._robots[robot_name] - global_joint_names = [ - make_global_joint_name(robot_name, joint) for joint in config.joint_names - ] - current_by_name: dict[str, float] = {} - if self._world_monitor is not None: - current = self._world_monitor.get_current_joint_state(robot_id) - if current is not None: - indexed_current = self._current_positions_by_name(robot_name, current) - if indexed_current is None: - return [] - current_by_name = indexed_current - projected: JointPath = [] - for waypoint in plan.path: - if len(waypoint.name) != len(waypoint.position): - logger.error( - "Cannot project plan for '%s': waypoint has %d names but %d positions", - robot_name, - len(waypoint.name), - len(waypoint.position), - ) - return [] - try: - assert_global_joint_names(waypoint.name) - except ValueError as exc: - logger.error("Cannot project plan for '%s': %s", robot_name, exc) - return [] - position_by_name = dict(zip(waypoint.name, waypoint.position, strict=True)) - positions: list[float] = [] - for local_name, global_name in zip( - config.joint_names, global_joint_names, strict=False - ): - if global_name in position_by_name: - positions.append(position_by_name[global_name]) - else: - if local_name in current_by_name: - positions.append(current_by_name[local_name]) - continue - logger.error( - "Cannot project plan for '%s': missing joint '%s'", - robot_name, - global_name, - ) - return [] - projected.append( - JointState( - name=list(config.joint_names), - position=positions, - ) - ) - return projected - - def _project_plan_to_robot_path_for_execution( - self, plan: GeneratedPlan, robot_name: RobotName - ) -> JointPath: - """Project a generated plan to one robot's local execution path.""" - return self._project_plan_path_for_robot(plan, robot_name) - - def _trajectory_from_robot_path( - self, robot_name: RobotName, path: JointPath - ) -> JointTrajectory | None: - """Generate a coordinator-facing trajectory from a local robot path.""" - if len(path) < 2: - logger.error("Plan projection for '%s' has fewer than two waypoints", robot_name) - return None - _, config, traj_gen = self._robots[robot_name] - trajectory = traj_gen.generate([list(state.position) for state in path]) - return JointTrajectory( - joint_names=make_global_joint_names(robot_name, config.joint_names), - points=trajectory.points, - timestamp=trajectory.timestamp, - ) - def _affected_robot_names(self, plan: GeneratedPlan) -> list[RobotName]: """Get stable robot names affected by a generated plan.""" assert self._world_monitor is not None @@ -675,7 +585,7 @@ def _affected_robot_names(self, plan: GeneratedPlan) -> list[RobotName]: def _store_generated_plan( self, group_ids: tuple[PlanningGroupID, ...], result: PlanningResult ) -> None: - """Store canonical generated plan and compatibility per-robot projections.""" + """Store the canonical generated plan.""" self._last_plan = GeneratedPlan( group_ids=group_ids, path=result.path, @@ -685,17 +595,6 @@ def _store_generated_plan( iterations=result.iterations, message=result.message, ) - self._planned_paths.clear() - self._planned_trajectories.clear() - for robot_name in self._affected_robot_names(self._last_plan): - projected_path = self._project_plan_to_robot_path_for_execution( - self._last_plan, robot_name - ) - if projected_path: - self._planned_paths[robot_name] = projected_path - trajectory = self._trajectory_from_robot_path(robot_name, projected_path) - if trajectory is not None: - self._planned_trajectories[robot_name] = trajectory def _plan_selected_path( self, group_ids: tuple[PlanningGroupID, ...], start: JointState, goal: JointState @@ -976,8 +875,6 @@ def clear_planned_path(self) -> bool: True if cleared """ self._last_plan = None - self._planned_paths.clear() - self._planned_trajectories.clear() return True @rpc @@ -1163,6 +1060,7 @@ def execute_plan( except Exception as exc: return self._fail(f"Failed to resolve generated plan: {exc}") robot_names = [robot_name] if robot_name is not None else affected + assert self._world_monitor is not None dispatches: list[tuple[RobotName, RobotModelConfig, JointTrajectory]] = [] for name in robot_names: @@ -1172,19 +1070,65 @@ def execute_plan( robot = self._get_robot(name) if robot is None: return False - resolved_name, _, config, _ = robot + resolved_name, robot_id, config, traj_gen = robot if not config.coordinator_task_name: logger.error(f"No coordinator_task_name for '{resolved_name}'") return False - projected_path = self._project_plan_to_robot_path_for_execution(plan, resolved_name) - trajectory = self._trajectory_from_robot_path(resolved_name, projected_path) - if trajectory is None: + + current_by_name: dict[str, float] = {} + current = self._world_monitor.get_current_joint_state(robot_id) + if current is not None: + indexed_current = self._current_positions_by_name(resolved_name, current) + if indexed_current is None: + return False + current_by_name = indexed_current + + global_joint_names = make_global_joint_names(resolved_name, config.joint_names) + local_path: list[JointState] = [] + for waypoint in plan.path: + if len(waypoint.name) != len(waypoint.position): + logger.error( + "Cannot execute plan for '%s': waypoint has %d names but %d positions", + resolved_name, + len(waypoint.name), + len(waypoint.position), + ) + return False + try: + assert_global_joint_names(waypoint.name) + except ValueError as exc: + logger.error("Cannot execute plan for '%s': %s", resolved_name, exc) + return False + selected_positions = dict(zip(waypoint.name, waypoint.position, strict=True)) + positions: list[float] = [] + for local_name, global_name in zip( + config.joint_names, global_joint_names, strict=True + ): + if global_name in selected_positions: + positions.append(selected_positions[global_name]) + elif local_name in current_by_name: + positions.append(current_by_name[local_name]) + else: + logger.error( + "Cannot execute plan for '%s': missing joint '%s'", + resolved_name, + global_name, + ) + return False + local_path.append(JointState(name=list(config.joint_names), position=positions)) + if len(local_path) < 2: + logger.error("Plan projection for '%s' has fewer than two waypoints", resolved_name) return False + local_trajectory = traj_gen.generate([list(state.position) for state in local_path]) + trajectory = JointTrajectory( + joint_names=list(global_joint_names), + points=local_trajectory.points, + timestamp=local_trajectory.timestamp, + ) dispatches.append((resolved_name, config, trajectory)) self._state = ManipulationState.EXECUTING - for name, config, trajectory in dispatches: - self._planned_trajectories[name] = trajectory + for _name, config, trajectory in dispatches: logger.info( "Executing: task='%s', %d pts, %.2fs", config.coordinator_task_name, @@ -1385,11 +1329,7 @@ def _wait_for_trajectory_completion( client = self._get_coordinator_client() if client is None or not config.coordinator_task_name: - # No coordinator — wait for trajectory duration as fallback - traj = self._planned_trajectories.get(rname) - if traj is not None: - logger.info(f"No coordinator status — waiting {traj.duration:.1f}s for trajectory") - time.sleep(traj.duration + 0.5) + logger.info("No coordinator status available for '%s'", rname) return True # Poll task state via task_invoke @@ -1410,13 +1350,7 @@ def _wait_for_trajectory_completion( # task_invoke returned None — task not found, assume done return True except Exception: - # Fallback: wait for trajectory duration - traj = self._planned_trajectories.get(rname) - if traj is not None: - remaining = traj.duration - (time.time() - start) - if remaining > 0: - logger.info(f"Status poll failed — waiting {remaining:.1f}s for trajectory") - time.sleep(remaining + 0.5) + logger.info("Status poll failed for '%s'", rname) return True time.sleep(poll_interval) diff --git a/dimos/manipulation/test_manipulation_module.py b/dimos/manipulation/test_manipulation_module.py index 9226a9e87c..4e902cb170 100644 --- a/dimos/manipulation/test_manipulation_module.py +++ b/dimos/manipulation/test_manipulation_module.py @@ -151,11 +151,8 @@ def test_plan_to_joints(self, module, joint_state_zeros): assert success is True assert module._state == ManipulationState.COMPLETED assert module.has_planned_path() is True - - assert "test_arm" in module._planned_trajectories - traj = module._planned_trajectories["test_arm"] - assert len(traj.points) > 1 - assert traj.duration > 0 + assert module._last_plan is not None + assert len(module._last_plan.path) > 1 def test_add_and_remove_obstacle(self, module, joint_state_zeros): """Test adding and removing obstacles.""" @@ -201,8 +198,14 @@ def test_planned_trajectory_uses_global_joint_names(self, module, joint_state_ze success = module.plan_to_joints(JointState(position=[0.05] * 7)) assert success is True - traj = module._planned_trajectories["test_arm"] - assert traj.joint_names == [f"test_arm/joint{i}" for i in range(1, 8)] + mock_client = MagicMock() + mock_client.task_invoke.return_value = True + module._coordinator_client = mock_client + + assert module.execute() is True + + trajectory = mock_client.task_invoke.call_args.args[2]["trajectory"] + assert trajectory.joint_names == [f"test_arm/joint{i}" for i in range(1, 8)] @pytest.mark.skipif(not _drake_available(), reason="Drake not installed") diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 5d615be1e6..008959a172 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -100,8 +100,6 @@ def _make_module(): module._lock = threading.Lock() module._error_message = "" module._robots = {} - module._planned_paths = {} - module._planned_trajectories = {} module._world_monitor = None module._planner = None module._kinematics = None @@ -294,7 +292,6 @@ def test_solve_ik_rpc_calls_configured_backend(self, robot_config): assert result is expected assert module._state == ManipulationState.COMPLETED - assert module._planned_paths == {} module._kinematics.solve.assert_called_once() _, kwargs = module._kinematics.solve.call_args assert kwargs["world"] is module._world_monitor.world @@ -350,7 +347,6 @@ def test_execute_requires_trajectory(self, robot_config): """Execute fails without planned trajectory.""" module = _make_module() module._robots = {"test_arm": ("id", robot_config, MagicMock())} - module._planned_trajectories = {} assert module.execute() is False @@ -471,21 +467,6 @@ def _make_joint_state(positions: list[float], name: list[str] | None = None) -> return JointState(name=name or [f"j{i}" for i in range(len(positions))], position=positions) -def _make_path(*points: list[float]) -> list[JointState]: - return [_make_joint_state(list(point)) for point in points] - - -def _make_trajectory(*points: tuple[float, list[float]]) -> JointTrajectory: - joint_names = [f"j{i}" for i in range(len(points[0][1]))] if points else [] - return JointTrajectory( - joint_names=joint_names, - points=[ - TrajectoryPoint(time_from_start=time_from_start, positions=positions) - for time_from_start, positions in points - ], - ) - - def _make_robot_config( name: str, joints: list[str], @@ -867,12 +848,19 @@ def test_execute_plan_dispatches_one_trajectory_per_affected_robot(self): assert right_trajectory.joint_names == ["right/j1", "right/j2"] assert [point.positions for point in right_trajectory.points] == [[3.0, 8.0], [6.0, 8.0]] - def test_project_plan_holds_non_selected_joints_from_current_state(self): + def test_execute_plan_holds_non_selected_joints_from_current_state(self): config = _make_robot_config("left", ["j1", "j2", "j3"], "task") module = _make_module_with_monitor(config) + generator = _trajectory_generator() + module._robots["left"] = ("robot_left", config, generator) + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_global_group("left", "arm", ["j2"]) + ] module._world_monitor.get_current_joint_state.return_value = JointState( name=["j1", "j2", "j3"], position=[10.0, 20.0, 30.0] ) + module._coordinator_client = MagicMock() + module._coordinator_client.task_invoke.return_value = True plan = GeneratedPlan( group_ids=("left/arm",), path=[ @@ -882,49 +870,33 @@ def test_project_plan_holds_non_selected_joints_from_current_state(self): status=PlanningStatus.SUCCESS, ) - projected = module._project_plan_path_for_robot(plan, "left") + assert module.execute_plan(plan) is True - assert [state.name for state in projected] == [["j1", "j2", "j3"], ["j1", "j2", "j3"]] - assert [state.position for state in projected] == [[10.0, 2.0, 30.0], [10.0, 3.0, 30.0]] + trajectory = module._coordinator_client.task_invoke.call_args.args[2]["trajectory"] + assert trajectory.joint_names == ["left/j1", "left/j2", "left/j3"] + assert [point.positions for point in trajectory.points] == [ + [10.0, 2.0, 30.0], + [10.0, 3.0, 30.0], + ] - def test_project_plan_rejects_local_waypoint_names(self): + def test_execute_plan_rejects_local_waypoint_names(self): config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) + module._world_monitor.world.resolve_planning_groups.return_value = [ + _make_global_group("left", "arm", ["j1"]) + ] module._world_monitor.get_current_joint_state.return_value = JointState( name=["j1", "j2"], position=[10.0, 20.0] ) + module._coordinator_client = MagicMock() plan = GeneratedPlan( group_ids=("left/arm",), path=[JointState(name=["j1"], position=[1.0])], status=PlanningStatus.SUCCESS, ) - assert module._project_plan_path_for_robot(plan, "left") == [] - - def test_project_plan_uses_global_names_for_robots_with_shared_local_names(self): - left_config = _make_robot_config("left", ["j1", "j2"], "left_task") - right_config = _make_robot_config("right", ["j1", "j2"], "right_task") - module = _make_module_with_monitor(left_config, right_config) - module._world_monitor.get_current_joint_state.side_effect = [ - JointState(name=["j1", "j2"], position=[10.0, 20.0]), - JointState(name=["j1", "j2"], position=[30.0, 40.0]), - ] - plan = GeneratedPlan( - group_ids=("left/arm", "right/arm"), - path=[ - JointState(name=["left/j1", "right/j1"], position=[1.0, 3.0]), - JointState(name=["left/j1", "right/j1"], position=[2.0, 4.0]), - ], - status=PlanningStatus.SUCCESS, - ) - - left_projected = module._project_plan_path_for_robot(plan, "left") - right_projected = module._project_plan_path_for_robot(plan, "right") - - assert [state.name for state in left_projected] == [["j1", "j2"], ["j1", "j2"]] - assert [state.position for state in left_projected] == [[1.0, 20.0], [2.0, 20.0]] - assert [state.name for state in right_projected] == [["j1", "j2"], ["j1", "j2"]] - assert [state.position for state in right_projected] == [[3.0, 40.0], [4.0, 40.0]] + assert module.execute_plan(plan) is False + module._coordinator_client.task_invoke.assert_not_called() def test_preview_path_with_last_plan_animates_generated_plan(self): config = _make_robot_config("left", ["j1", "j2"], "task") @@ -952,12 +924,7 @@ def test_has_and_clear_planned_path_use_last_plan(self): path=[JointState(name=["left/j1"], position=[1.0])], status=PlanningStatus.SUCCESS, ) - module._planned_paths = {"left": _make_path([1.0])} - module._planned_trajectories = {"left": _make_trajectory((0.0, [1.0]))} - assert module.has_planned_path() is True assert module.clear_planned_path() is True assert module.has_planned_path() is False assert module._last_plan is None - assert module._planned_paths == {} - assert module._planned_trajectories == {} From c07d8da55d536043233b07f518b1385fe3e97ffa Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 15:30:01 -0700 Subject: [PATCH 049/110] refactor: remove legacy preview path api --- dimos/manipulation/manipulation_module.py | 25 +++--------------- .../planning/examples/manipulation_client.py | 5 ++-- dimos/manipulation/test_manipulation_unit.py | 26 +++++++++---------- 3 files changed, 19 insertions(+), 37 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index c0932c7fc9..30375f7333 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -15,7 +15,7 @@ """Manipulation Module - Motion planning with ControlCoordinator execution. Base module providing core manipulation infrastructure: -- @rpc: Low-level building blocks (plan_to_pose, plan_to_joints, preview_path, execute) +- @rpc: Low-level building blocks (plan_to_pose, plan_to_joints, preview_plan, execute) - @skill (short-horizon): Single-step actions (move_to_pose, open_gripper, go_home, go_init) Subclass PickAndPlaceModule (pick_and_place_module.py) adds perception integration @@ -691,7 +691,7 @@ def solve_ik( @rpc def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: - """Plan motion to pose. Use preview_path() then execute(). + """Plan motion to pose. Use preview_plan() then execute(). Args: pose: Target end-effector pose @@ -756,7 +756,7 @@ def plan_to_poses( @rpc def plan_to_joints(self, joints: JointState, robot_name: RobotName | None = None) -> bool: - """Plan motion to joint config. Use preview_path() then execute(). + """Plan motion to joint config. Use preview_plan() then execute(). Args: joints: Target joint state (names + positions) @@ -830,23 +830,6 @@ def preview_plan( self._world_monitor.animate_plan(plan, animation_duration) return True - @rpc - def preview_path( - self, - duration: float | None = None, - robot_name: RobotName | None = None, - target_fps: float = 30.0, - ) -> bool: - """Preview the last generated plan in the visualizer. - - Args: - duration: Total animation duration in seconds. - robot_name: Optional compatibility filter; the stored plan must affect it. - target_fps: Deprecated; generated-plan preview timing is backend-owned. - """ - _ = target_fps - return self.preview_plan(self._last_plan, duration, robot_name) - @rpc def has_planned_path(self) -> bool: """Check if there's a planned path ready. @@ -1385,7 +1368,7 @@ def _preview_execute_wait( preview_duration: Duration to animate the preview in Meshcat (seconds) """ logger.info("Previewing trajectory...") - self.preview_path(preview_duration, robot_name) + self.preview_plan(duration=preview_duration, robot_name=robot_name) logger.info("Executing trajectory...") if not self.execute(robot_name): diff --git a/dimos/manipulation/planning/examples/manipulation_client.py b/dimos/manipulation/planning/examples/manipulation_client.py index 1185f28f21..335d549b85 100644 --- a/dimos/manipulation/planning/examples/manipulation_client.py +++ b/dimos/manipulation/planning/examples/manipulation_client.py @@ -163,10 +163,9 @@ def plan_pose( def preview( duration: float | None = None, robot_name: str | None = None, - target_fps: float = 30.0, ) -> bool: - """Preview planned path in Meshcat.""" - return _client.preview_path(duration, robot_name, target_fps) + """Preview the last generated plan in Meshcat.""" + return _client.preview_plan(None, duration, robot_name) def execute(robot_name: str | None = None) -> bool: diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 008959a172..9ac5d13918 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -712,7 +712,7 @@ def test_dismiss_preview_routes_to_monitor(self): module._world_monitor.hide_preview.assert_called_once_with(group_ids) module._world_monitor.publish_visualization.assert_called_once_with() - def test_preview_path_delegates_last_plan_with_default_duration(self): + def test_preview_plan_uses_last_plan_with_default_duration(self): module = _make_module() module._world_monitor = MagicMock() module._last_plan = GeneratedPlan( @@ -721,11 +721,11 @@ def test_preview_path_delegates_last_plan_with_default_duration(self): status=PlanningStatus.SUCCESS, ) - assert module.preview_path(target_fps=2.0) is True + assert module.preview_plan() is True module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 3.0) - def test_preview_path_explicit_duration_overrides_default(self): + def test_preview_plan_explicit_duration_overrides_default(self): module = _make_module() module._world_monitor = MagicMock() module._last_plan = GeneratedPlan( @@ -734,11 +734,11 @@ def test_preview_path_explicit_duration_overrides_default(self): status=PlanningStatus.SUCCESS, ) - assert module.preview_path(duration=1.5, target_fps=2.0) is True + assert module.preview_plan(duration=1.5) is True module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 1.5) - def test_preview_path_respects_robot_filter(self): + def test_preview_plan_respects_robot_filter(self): module = _make_module() module._world_monitor = MagicMock() module._world_monitor.world.resolve_planning_groups.return_value = [ @@ -750,11 +750,11 @@ def test_preview_path_respects_robot_filter(self): status=PlanningStatus.SUCCESS, ) - assert module.preview_path(robot_name="arm") is True + assert module.preview_plan(robot_name="arm") is True module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 3.0) - def test_preview_path_rejects_unaffected_robot_filter(self): + def test_preview_plan_rejects_unaffected_robot_filter(self): module = _make_module() module._world_monitor = MagicMock() module._world_monitor.world.resolve_planning_groups.return_value = [ @@ -766,17 +766,17 @@ def test_preview_path_rejects_unaffected_robot_filter(self): status=PlanningStatus.SUCCESS, ) - assert module.preview_path(robot_name="other") is False + assert module.preview_plan(robot_name="other") is False module._world_monitor.animate_plan.assert_not_called() - def test_preview_path_returns_false_for_missing_inputs(self): + def test_preview_plan_returns_false_for_missing_inputs(self): module = _make_module() - assert module.preview_path() is False + assert module.preview_plan() is False module._world_monitor = MagicMock() - assert module.preview_path() is False + assert module.preview_plan() is False class TestGeneratedPlanProjection: @@ -898,7 +898,7 @@ def test_execute_plan_rejects_local_waypoint_names(self): assert module.execute_plan(plan) is False module._coordinator_client.task_invoke.assert_not_called() - def test_preview_path_with_last_plan_animates_generated_plan(self): + def test_preview_plan_with_last_plan_animates_generated_plan(self): config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) module._world_monitor.world.resolve_planning_groups.return_value = [ @@ -913,7 +913,7 @@ def test_preview_path_with_last_plan_animates_generated_plan(self): status=PlanningStatus.SUCCESS, ) - assert module.preview_path(robot_name="left", target_fps=0.0) is True + assert module.preview_plan(robot_name="left") is True module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 3.0) From 95548e502e3fe941d3ce6ae447fc37422bb9380d Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 16:59:18 -0700 Subject: [PATCH 050/110] refactor: decouple planning group registry --- CONTEXT.md | 12 +- dimos/manipulation/manipulation_module.py | 79 ++++++------ .../manipulation/planning/groups/__init__.py | 53 +++++++++ .../discovery.py} | 13 +- dimos/manipulation/planning/groups/models.py | 112 ++++++++++++++++++ .../manipulation/planning/groups/registry.py | 103 ++++++++++++++++ .../utils.py} | 41 +------ .../planning/kinematics/jacobian_ik.py | 50 +++++--- .../planning/kinematics/pink_ik.py | 53 +++++---- .../kinematics/test_jacobian_ik_selection.py | 38 ++++-- .../planning/kinematics/test_pink_ik.py | 60 +++++----- .../planning/monitor/world_monitor.py | 36 ++++-- .../planning/planners/rrt_planner.py | 68 +++++++---- .../planners/test_rrt_planner_selection.py | 74 ++++-------- dimos/manipulation/planning/spec/config.py | 6 +- dimos/manipulation/planning/spec/models.py | 75 +----------- dimos/manipulation/planning/spec/protocols.py | 30 +---- .../planning/test_planning_group_utils.py | 8 +- .../planning/test_planning_groups.py | 2 +- .../planning/world/drake_world.py | 96 ++------------- .../world/test_drake_world_planning_groups.py | 92 +++++++------- dimos/manipulation/test_manipulation_unit.py | 111 ++++++++++------- dimos/robot/config.py | 2 +- 23 files changed, 685 insertions(+), 529 deletions(-) create mode 100644 dimos/manipulation/planning/groups/__init__.py rename dimos/manipulation/planning/{planning_groups.py => groups/discovery.py} (97%) create mode 100644 dimos/manipulation/planning/groups/models.py create mode 100644 dimos/manipulation/planning/groups/registry.py rename dimos/manipulation/planning/{planning_group_utils.py => groups/utils.py} (78%) diff --git a/CONTEXT.md b/CONTEXT.md index 701648bbed..52f23f3a7c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -17,7 +17,7 @@ A control-layer routing identity for a hardware component. For manipulator robot _Avoid_: Robot name when discussing planning semantics, world robot ID **Planning Group**: -A named selectable serial kinematic chain of robot joints used as the unit of manipulation planning. A planning group is defined by its chain/joints, not by end-effector metadata. +A named selectable serial kinematic chain of robot joints used as the unit of manipulation planning. After binding to a robot name, it is identified by a planning group ID and remains independent of backend world robot IDs. _Avoid_: Move group, movegroup **Planning Group Definition**: @@ -28,10 +28,6 @@ _Avoid_: Runtime group, robot ID Separate metadata used for pose-targeted operations. For a planning group defined by a chain, the end-effector link is the chain tip. For a planning group defined only by joints, there is no end-effector link. _Avoid_: Planning group definition -**Resolved Planning Group**: -A planning group after model-level declarations have been bound to a concrete robot name and planning world. -_Avoid_: Planning group config, robot ID - **Planning Group Selection**: The set of one or more planning groups chosen for a planning request. _Avoid_: Composite group @@ -52,9 +48,9 @@ _Avoid_: Bare group name, robot ID The generated fallback planning group used by robot-scoped compatibility APIs when no planning group is specified explicitly. It is not inferred from arbitrary SRDF group uniqueness. _Avoid_: Unique planning group, primary planning group -**Planning Group Descriptor**: -A read-only snapshot returned by query APIs that describes an available planning group and may be used ergonomically as a planning group selector. -_Avoid_: Live planning group handle, resolved planning group +**Robot-Scoped Compatibility API**: +A convenience API that accepts a robot name for common single-robot calls but immediately delegates to planning-group APIs through the robot's default planning group. It does not define a separate planning model, storage model, or groupless execution path. +_Avoid_: Robot-scoped planner, groupless API **Joint State**: A joint-name-keyed robot state that can represent any set of joints and is not inherently coupled to a robot, planning group, planning group selection, or joint-name scope. At flat multi-robot or coordinator boundaries, joint names are required and are global joint names. Robot identity and local-vs-global meaning are provided by the API boundary or containing type, not by extra fields on the generic joint state. diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 30375f7333..f80d9f5c4e 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -40,23 +40,21 @@ from dimos.core.module import Module, ModuleConfig from dimos.core.stream import In from dimos.manipulation.planning.factory import create_kinematics, create_planner +from dimos.manipulation.planning.groups import ( + PlanningGroup, + joint_target_to_global_names, + planning_group_id_from_selector, +) from dimos.manipulation.planning.kinematics.config import ( JacobianKinematicsConfig, ManipulationKinematicsConfig, kinematics_config_from_name, ) from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor -from dimos.manipulation.planning.planning_group_utils import ( - joint_target_to_global_names, - planning_group_id_from_selector, - primary_pose_planning_group_id_for_robot, -) -from dimos.manipulation.planning.planning_groups import FALLBACK_PLANNING_GROUP_NAME from dimos.manipulation.planning.planning_identifiers import ( assert_global_joint_names, assert_local_joint_names, make_global_joint_names, - make_planning_group_id, ) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType @@ -64,7 +62,6 @@ GeneratedPlan, IKResult, Obstacle, - PlanningGroupDescriptor, PlanningGroupID, PlanningResult, RobotName, @@ -345,9 +342,7 @@ def _tf_publish_loop(self) -> None: target_frame = config.end_effector_link pose_group_id = self._primary_pose_group_id_for_robot(config.name) if pose_group_id is not None: - pose_group = self._world_monitor.world.resolve_planning_groups( - (pose_group_id,) - )[0] + pose_group = self._world_monitor.planning_groups.get(pose_group_id) target_frame = pose_group.tip_link ee_pose = self._world_monitor.get_group_pose(pose_group_id) else: @@ -484,22 +479,19 @@ def _fail(self, msg: str) -> bool: def _default_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: """Return the generated fallback group used by robot-scoped wrappers.""" assert self._world_monitor is not None - group_id = make_planning_group_id(robot_name, FALLBACK_PLANNING_GROUP_NAME) - if any(group.id == group_id for group in self._world_monitor.world.list_planning_groups()): + group_id = self._world_monitor.planning_groups.default_group_id_for_robot(robot_name) + if group_id is not None: return group_id logger.error( - "Robot '%s' has no generated default planning group '%s'; use explicit group APIs", + "Robot '%s' has no generated default planning group; use explicit group APIs", robot_name, - group_id, ) return None def _primary_pose_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: """Return the first pose-targetable group for robot-scoped compatibility paths.""" assert self._world_monitor is not None - return primary_pose_planning_group_id_for_robot( - self._world_monitor.world.list_planning_groups(), robot_name - ) + return self._world_monitor.planning_groups.primary_pose_group_id_for_robot(robot_name) def _current_positions_by_name( self, robot_name: RobotName, current: JointState @@ -527,14 +519,18 @@ def _current_positions_by_name( def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> JointState | None: """Collect current state for exactly the selected global joints.""" assert self._world_monitor is not None - resolved_groups = self._world_monitor.world.resolve_planning_groups(group_ids) + selection = self._world_monitor.planning_groups.select(group_ids) - robot_names_by_id: dict[WorldRobotID, RobotName] = {} - for group in resolved_groups: - robot_names_by_id.setdefault(group.robot_id, group.robot_name) + robot_ids_by_name: dict[RobotName, WorldRobotID] = {} + for robot_name in selection.robot_names: + try: + robot_ids_by_name[robot_name] = self._robots[robot_name][0] + except KeyError: + logger.error("Robot '%s' is not registered", robot_name) + return None - current_by_robot: dict[WorldRobotID, dict[str, float]] = {} - for robot_id, robot_name in robot_names_by_id.items(): + current_by_robot: dict[RobotName, dict[str, float]] = {} + for robot_name, robot_id in robot_ids_by_name.items(): current = self._world_monitor.get_current_joint_state(robot_id) if current is None: logger.error("No joint state for robot '%s'", robot_name) @@ -542,12 +538,12 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint indexed_current = self._current_positions_by_name(robot_name, current) if indexed_current is None: return None - current_by_robot[robot_id] = indexed_current + current_by_robot[robot_name] = indexed_current names: list[str] = [] positions: list[float] = [] - for group in resolved_groups: - robot_state = current_by_robot[group.robot_id] + for group in selection.groups: + robot_state = current_by_robot[group.robot_name] for resolved_name, local_name in zip( group.joint_names, group.local_joint_names, strict=True ): @@ -565,7 +561,7 @@ def _joint_target_to_global_names( ) -> JointState | None: """Convert a group joint target to global joint names in group order.""" assert self._world_monitor is not None - group = self._world_monitor.world.resolve_planning_groups((group_id,))[0] + group = self._world_monitor.planning_groups.get(group_id) try: return joint_target_to_global_names(group, target) except ValueError as exc: @@ -575,12 +571,7 @@ def _joint_target_to_global_names( def _affected_robot_names(self, plan: GeneratedPlan) -> list[RobotName]: """Get stable robot names affected by a generated plan.""" assert self._world_monitor is not None - resolved_groups = self._world_monitor.world.resolve_planning_groups(plan.group_ids) - names: list[RobotName] = [] - for group in resolved_groups: - if group.robot_name not in names: - names.append(group.robot_name) - return names + return list(self._world_monitor.planning_groups.select(plan.group_ids).robot_names) def _store_generated_plan( self, group_ids: tuple[PlanningGroupID, ...], result: PlanningResult @@ -603,7 +594,7 @@ def _plan_selected_path( assert self._world_monitor and self._planner result = self._planner.plan_selected_joint_path( world=self._world_monitor.world, - group_ids=group_ids, + selection=self._world_monitor.planning_groups.select(group_ids), start=start, goal=goal, timeout=self.config.planning_timeout, @@ -712,8 +703,8 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: @rpc def plan_to_poses( self, - pose_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, Pose], - auxiliary_groups: Sequence[PlanningGroupID | PlanningGroupDescriptor] = (), + pose_targets: Mapping[PlanningGroupID | PlanningGroup, Pose], + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup] = (), ) -> bool: """Plan to one or more group pose targets with optional auxiliary groups.""" if self._world_monitor is None or self._kinematics is None: @@ -745,8 +736,13 @@ def plan_to_poses( ik = self._kinematics.solve_pose_targets( world=self._world_monitor.world, - pose_targets=stamped_targets, - auxiliary_groups=auxiliary_ids, + pose_targets={ + self._world_monitor.planning_groups.get(group_id): pose + for group_id, pose in stamped_targets.items() + }, + auxiliary_groups=tuple( + self._world_monitor.planning_groups.get(group_id) for group_id in auxiliary_ids + ), seed=start, check_collision=True, ) @@ -774,7 +770,7 @@ def plan_to_joints(self, joints: JointState, robot_name: RobotName | None = None @rpc def plan_to_joint_targets( - self, joint_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, JointState] + self, joint_targets: Mapping[PlanningGroupID | PlanningGroup, JointState] ) -> bool: """Plan to joint targets keyed by planning group.""" if self._world_monitor is None or self._planner is None: @@ -896,8 +892,7 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] "source": group.source, "has_pose_target": group.has_pose_target, } - for group in self._world_monitor.world.list_planning_groups() - if group.robot_name == robot_name + for group in self._world_monitor.planning_groups.groups_for_robot(robot_name) ] if self._world_monitor is not None else [] diff --git a/dimos/manipulation/planning/groups/__init__.py b/dimos/manipulation/planning/groups/__init__.py new file mode 100644 index 0000000000..ba142e5654 --- /dev/null +++ b/dimos/manipulation/planning/groups/__init__.py @@ -0,0 +1,53 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Planning-group domain models, discovery, registry, and helpers.""" + +from dimos.manipulation.planning.groups.discovery import ( + FALLBACK_PLANNING_GROUP_NAME, + PlanningGroupDiscoveryError, + discover_planning_group_definitions, + generate_fallback_planning_group, + parse_srdf_planning_groups, +) +from dimos.manipulation.planning.groups.models import ( + PlanningGroup, + PlanningGroupDefinition, + PlanningGroupSelection, + PlanningGroupSource, +) +from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry +from dimos.manipulation.planning.groups.utils import ( + filter_joint_state_to_selected_joints, + joint_target_to_global_names, + matching_global_joint_name, + planning_group_id_from_selector, +) + +__all__ = [ + "FALLBACK_PLANNING_GROUP_NAME", + "PlanningGroup", + "PlanningGroupDefinition", + "PlanningGroupDiscoveryError", + "PlanningGroupRegistry", + "PlanningGroupSelection", + "PlanningGroupSource", + "discover_planning_group_definitions", + "filter_joint_state_to_selected_joints", + "generate_fallback_planning_group", + "joint_target_to_global_names", + "matching_global_joint_name", + "parse_srdf_planning_groups", + "planning_group_id_from_selector", +] diff --git a/dimos/manipulation/planning/planning_groups.py b/dimos/manipulation/planning/groups/discovery.py similarity index 97% rename from dimos/manipulation/planning/planning_groups.py rename to dimos/manipulation/planning/groups/discovery.py index 2b8c362512..5e9ddfa25d 100644 --- a/dimos/manipulation/planning/planning_groups.py +++ b/dimos/manipulation/planning/groups/discovery.py @@ -21,7 +21,7 @@ import warnings import xml.etree.ElementTree as ET -from dimos.manipulation.planning.spec.models import PlanningGroupDefinition +from dimos.manipulation.planning.groups.models import PlanningGroupDefinition from dimos.robot.model_parser import JointDescription, ModelDescription from dimos.utils.logging_config import setup_logger @@ -204,7 +204,7 @@ def _parse_chain_group( try: ordered_joints = _ordered_joints_between_links(model, base_link, tip_link) - controlled_joints = [j for j in ordered_joints if j.type != "fixed"] + controlled_joints = [joint for joint in ordered_joints if joint.type != "fixed"] _validate_controllable(group_name, controlled_joints, controllable_joint_names) except PlanningGroupDiscoveryError as exc: _warn(f"Skipping SRDF chain group {group_name} in {srdf_path}: {exc}") @@ -356,12 +356,3 @@ def _validate_controllable( raise PlanningGroupDiscoveryError( f"planning group {group_name} includes joints outside controllable set: {missing}" ) - - -__all__ = [ - "FALLBACK_PLANNING_GROUP_NAME", - "PlanningGroupDiscoveryError", - "discover_planning_group_definitions", - "generate_fallback_planning_group", - "parse_srdf_planning_groups", -] diff --git a/dimos/manipulation/planning/groups/models.py b/dimos/manipulation/planning/groups/models.py new file mode 100644 index 0000000000..c08b1bd4b5 --- /dev/null +++ b/dimos/manipulation/planning/groups/models.py @@ -0,0 +1,112 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Backend-independent planning-group domain models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, TypeAlias + +from dimos.manipulation.planning.spec.models import ( + GlobalJointName, + LocalModelJointName, + PlanningGroupID, + RobotName, +) + +PlanningGroupSource: TypeAlias = Literal["srdf", "fallback"] + + +@dataclass(frozen=True) +class PlanningGroupDefinition: + """Model-level declaration of a planning group. + + Joint names are local model names. The definition is safe to store on + ``RobotModelConfig`` and is not bound to any runtime world robot ID. + """ + + name: str + joint_names: tuple[LocalModelJointName, ...] + base_link: str + tip_link: str | None = None + source: PlanningGroupSource = "srdf" + + @property + def has_pose_target(self) -> bool: + """Whether this group has a valid pose target frame.""" + return self.tip_link is not None + + +@dataclass(frozen=True) +class PlanningGroup: + """Public backend-independent planning group. + + A planning group exposes stable public IDs and global joint names for + planning APIs. It intentionally does not include backend runtime robot IDs. + """ + + id: PlanningGroupID + robot_name: RobotName + group_name: str + joint_names: tuple[GlobalJointName, ...] + local_joint_names: tuple[LocalModelJointName, ...] + base_link: str + tip_link: str | None = None + source: PlanningGroupSource = "srdf" + + @property + def has_pose_target(self) -> bool: + """Whether this group can be directly pose-targeted.""" + return self.tip_link is not None + + +@dataclass(frozen=True) +class PlanningGroupSelection: + """Validated ordered selection of planning groups. + + Selection validates ID existence and selected-joint overlap outside any + world backend. Requested group order is preserved. + """ + + groups: tuple[PlanningGroup, ...] + group_ids: tuple[PlanningGroupID, ...] + joint_names: tuple[GlobalJointName, ...] + robot_names: tuple[RobotName, ...] + + @classmethod + def from_groups(cls, groups: tuple[PlanningGroup, ...]) -> PlanningGroupSelection: + """Build a selection, rejecting overlapping selected global joints.""" + seen_joints: dict[GlobalJointName, PlanningGroupID] = {} + joint_names: list[GlobalJointName] = [] + robot_names: list[RobotName] = [] + for group in groups: + if group.robot_name not in robot_names: + robot_names.append(group.robot_name) + for joint_name in group.joint_names: + previous_group_id = seen_joints.get(joint_name) + if previous_group_id is not None: + raise ValueError( + "Selected planning groups overlap on global joint " + f"{joint_name}: {previous_group_id} and {group.id}" + ) + seen_joints[joint_name] = group.id + joint_names.append(joint_name) + + return cls( + groups=groups, + group_ids=tuple(group.id for group in groups), + joint_names=tuple(joint_names), + robot_names=tuple(robot_names), + ) diff --git a/dimos/manipulation/planning/groups/registry.py b/dimos/manipulation/planning/groups/registry.py new file mode 100644 index 0000000000..4006f38a13 --- /dev/null +++ b/dimos/manipulation/planning/groups/registry.py @@ -0,0 +1,103 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Backend-independent planning-group registry.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from dimos.manipulation.planning.groups.discovery import FALLBACK_PLANNING_GROUP_NAME +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection +from dimos.manipulation.planning.planning_identifiers import ( + make_global_joint_names, + make_planning_group_id, +) +from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName + +if TYPE_CHECKING: + from dimos.manipulation.planning.spec.config import RobotModelConfig + + +class PlanningGroupRegistry: + """Registry of public planning groups derived from robot configs.""" + + def __init__(self, robot_configs: Iterable[RobotModelConfig] = ()) -> None: + self._groups: dict[PlanningGroupID, PlanningGroup] = {} + self._groups_by_robot: dict[RobotName, list[PlanningGroup]] = {} + for config in robot_configs: + self.add_robot(config) + + def add_robot(self, config: RobotModelConfig) -> None: + """Register all planning groups declared by one robot config.""" + if config.name in self._groups_by_robot: + raise ValueError(f"Robot '{config.name}' is already registered") + + robot_groups: list[PlanningGroup] = [] + for definition in config.planning_groups: + group_id = make_planning_group_id(config.name, definition.name) + if group_id in self._groups: + raise ValueError(f"Planning group '{group_id}' is already registered") + group = PlanningGroup( + id=group_id, + robot_name=config.name, + group_name=definition.name, + joint_names=tuple(make_global_joint_names(config.name, definition.joint_names)), + local_joint_names=definition.joint_names, + base_link=definition.base_link, + tip_link=definition.tip_link, + source=definition.source, + ) + self._groups[group_id] = group + robot_groups.append(group) + self._groups_by_robot[config.name] = robot_groups + + def list(self) -> tuple[PlanningGroup, ...]: + """List planning groups in robot registration order.""" + groups: list[PlanningGroup] = [] + for robot_groups in self._groups_by_robot.values(): + groups.extend(robot_groups) + return tuple(groups) + + def get(self, group_id: PlanningGroupID) -> PlanningGroup: + """Return one planning group by public ID.""" + try: + return self._groups[group_id] + except KeyError as exc: + raise KeyError(f"Unknown planning group ID: {group_id}") from exc + + def select(self, group_ids: Iterable[PlanningGroupID]) -> PlanningGroupSelection: + """Validate and return an ordered planning-group selection.""" + return PlanningGroupSelection.from_groups( + tuple(self.get(group_id) for group_id in group_ids) + ) + + def groups_for_robot(self, robot_name: RobotName) -> tuple[PlanningGroup, ...]: + """Return planning groups for one robot.""" + return tuple(self._groups_by_robot.get(robot_name, ())) + + def default_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: + """Return the generated fallback group ID for robot-scoped wrappers.""" + group_id = make_planning_group_id(robot_name, FALLBACK_PLANNING_GROUP_NAME) + return group_id if group_id in self._groups else None + + def primary_pose_group_id_for_robot(self, robot_name: RobotName) -> PlanningGroupID | None: + """Return the first pose-targetable group ID for compatibility paths.""" + # TODO: Replace this compatibility selection with either one TF publication per + # pose-targetable planning group or backend-level whole-robot TF publishing. + for group in self.groups_for_robot(robot_name): + if group.has_pose_target: + return group.id + return None diff --git a/dimos/manipulation/planning/planning_group_utils.py b/dimos/manipulation/planning/groups/utils.py similarity index 78% rename from dimos/manipulation/planning/planning_group_utils.py rename to dimos/manipulation/planning/groups/utils.py index 3b2db4701c..5c854ae60f 100644 --- a/dimos/manipulation/planning/planning_group_utils.py +++ b/dimos/manipulation/planning/groups/utils.py @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Shared helpers for planning-group selector and joint-state projection.""" +"""Shared helpers for planning-group selectors and joint-state projection.""" from collections.abc import Mapping, Sequence +from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.planning_identifiers import ( assert_global_joint_names, assert_local_joint_names, @@ -24,50 +25,18 @@ from dimos.manipulation.planning.spec.models import ( GlobalJointName, LocalModelJointName, - PlanningGroupDescriptor, PlanningGroupID, - ResolvedPlanningGroup, - RobotName, ) from dimos.msgs.sensor_msgs.JointState import JointState -def planning_group_id_from_selector( - selector: PlanningGroupID | PlanningGroupDescriptor, -) -> PlanningGroupID: +def planning_group_id_from_selector(selector: PlanningGroupID | PlanningGroup) -> PlanningGroupID: """Return the planning-group ID represented by a selector.""" - if isinstance(selector, PlanningGroupDescriptor): + if isinstance(selector, PlanningGroup): return selector.id return selector -def single_planning_group_id_for_robot( - groups: Sequence[PlanningGroupDescriptor], - robot_name: RobotName, -) -> PlanningGroupID: - """Return a robot's only planning group ID, or raise if ambiguous.""" - group_ids = [group.id for group in groups if group.robot_name == robot_name] - if len(group_ids) != 1: - raise ValueError( - f"Robot '{robot_name}' has {len(group_ids)} planning groups; " - "select a planning group explicitly" - ) - return group_ids[0] - - -def primary_pose_planning_group_id_for_robot( - groups: Sequence[PlanningGroupDescriptor], - robot_name: RobotName, -) -> PlanningGroupID | None: - """Return the first pose-targetable group ID for compatibility paths.""" - # TODO: Replace this compatibility selection with either one TF publication per - # pose-targetable planning group or backend-level whole-robot TF publishing. - for group in groups: - if group.robot_name == robot_name and group.has_pose_target: - return group.id - return None - - def matching_global_joint_name( positions_by_name: Mapping[str, float], local_joint_name: LocalModelJointName ) -> GlobalJointName | None: @@ -113,7 +82,7 @@ def filter_joint_state_to_selected_joints( def joint_target_to_global_names( - group: ResolvedPlanningGroup, + group: PlanningGroup, target: JointState, ) -> JointState: """Convert a group joint target to global joint names in group order. diff --git a/dimos/manipulation/planning/kinematics/jacobian_ik.py b/dimos/manipulation/planning/kinematics/jacobian_ik.py index afdef5d5d0..dc77a68d6b 100644 --- a/dimos/manipulation/planning/kinematics/jacobian_ik.py +++ b/dimos/manipulation/planning/kinematics/jacobian_ik.py @@ -30,15 +30,15 @@ import numpy as np -from dimos.manipulation.planning.planning_group_utils import ( +from dimos.manipulation.planning.groups import ( + PlanningGroup, + PlanningGroupSelection, filter_joint_state_to_selected_joints, - planning_group_id_from_selector, ) from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import ( IKResult, - PlanningGroupDescriptor, - PlanningGroupID, + RobotName, WorldRobotID, ) from dimos.manipulation.planning.spec.protocols import WorldSpec @@ -208,8 +208,8 @@ def solve( def solve_pose_targets( self, world: WorldSpec, - pose_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, PoseStamped], - auxiliary_groups: Sequence[PlanningGroupID | PlanningGroupDescriptor] = (), + pose_targets: Mapping[PlanningGroup, PoseStamped], + auxiliary_groups: Sequence[PlanningGroup] = (), seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, @@ -225,43 +225,43 @@ def solve_pose_targets( IKStatus.NO_SOLUTION, "At least one pose target is required" ) - pose_group_ids = tuple( - planning_group_id_from_selector(group) for group in pose_targets.keys() - ) - if len(pose_group_ids) != 1 or auxiliary_groups: + pose_groups = tuple(pose_targets.keys()) + if len(pose_groups) != 1 or auxiliary_groups: return _create_failure_result( IKStatus.NO_SOLUTION, "JacobianIK supports exactly one pose target and no auxiliary planning groups", ) try: - resolved_groups = world.resolve_planning_groups(pose_group_ids) + selection = PlanningGroupSelection.from_groups(pose_groups + tuple(auxiliary_groups)) + robot_ids_by_name = _robot_ids_by_name(world, selection.robot_names) except (KeyError, ValueError) as exc: return _create_failure_result(IKStatus.NO_SOLUTION, str(exc)) - target_group = next(group for group in resolved_groups if group.id == pose_group_ids[0]) + target_group = pose_groups[0] if not target_group.has_pose_target: return _create_failure_result( IKStatus.NO_SOLUTION, f"Planning group '{target_group.id}' has no pose target frame", ) - robot_ids = {group.robot_id for group in resolved_groups} + robot_ids = {robot_ids_by_name[group.robot_name] for group in selection.groups} if len(robot_ids) != 1: return _create_failure_result( IKStatus.NO_SOLUTION, "JacobianIK does not support cross-robot pose IK", ) + robot_id = robot_ids_by_name[target_group.robot_name] full_seed = seed if full_seed is None: with world.scratch_context() as ctx: - full_seed = world.get_joint_state(ctx, target_group.robot_id) + full_seed = world.get_joint_state(ctx, robot_id) target_pose = pose_targets[next(iter(pose_targets.keys()))] result = self.solve( world=world, - robot_id=target_group.robot_id, + robot_id=robot_id, target_pose=target_pose, seed=full_seed, position_tolerance=position_tolerance, @@ -273,7 +273,7 @@ def solve_pose_targets( return result selected_joint_names: list[str] = [] - for group in resolved_groups: + for group in selection.groups: selected_joint_names.extend(group.joint_names) try: result.joint_state = filter_joint_state_to_selected_joints( @@ -531,3 +531,21 @@ def _create_failure_result( iterations=iterations, message=message, ) + + +def _robot_ids_by_name( + world: WorldSpec, robot_names: tuple[RobotName, ...] +) -> dict[RobotName, WorldRobotID]: + robot_ids_by_name: dict[RobotName, WorldRobotID] = {} + for robot_name in robot_names: + matches = [ + robot_id + for robot_id in world.get_robot_ids() + if world.get_robot_config(robot_id).name == robot_name + ] + if not matches: + raise KeyError(f"Robot '{robot_name}' not found") + if len(matches) > 1: + raise ValueError(f"Robot name '{robot_name}' is not unique in planning world") + robot_ids_by_name[robot_name] = matches[0] + return robot_ids_by_name diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index e7a411ffdf..bb5dbf62b8 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -25,18 +25,18 @@ import numpy as np -from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig -from dimos.manipulation.planning.planning_group_utils import ( +from dimos.manipulation.planning.groups import ( + PlanningGroup, + PlanningGroupSelection, filter_joint_state_to_selected_joints, matching_global_joint_name, - planning_group_id_from_selector, ) +from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import ( IKResult, - PlanningGroupDescriptor, - PlanningGroupID, + RobotName, WorldRobotID, ) from dimos.manipulation.planning.spec.protocols import WorldSpec @@ -178,8 +178,8 @@ def solve( def solve_pose_targets( self, world: WorldSpec, - pose_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, PoseStamped], - auxiliary_groups: Sequence[PlanningGroupID | PlanningGroupDescriptor] = (), + pose_targets: Mapping[PlanningGroup, PoseStamped], + auxiliary_groups: Sequence[PlanningGroup] = (), seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, @@ -190,36 +190,31 @@ def solve_pose_targets( if not pose_targets: return _failure(IKStatus.NO_SOLUTION, "At least one pose target is required") - pose_group_ids = tuple( - planning_group_id_from_selector(group) for group in pose_targets.keys() - ) - auxiliary_group_ids = tuple( - planning_group_id_from_selector(group) for group in auxiliary_groups - ) - selected_group_ids = pose_group_ids + auxiliary_group_ids + pose_groups = tuple(pose_targets.keys()) try: - resolved_groups = world.resolve_planning_groups(selected_group_ids) + selection = PlanningGroupSelection.from_groups(pose_groups + tuple(auxiliary_groups)) + robot_ids_by_name = _robot_ids_by_name(world, selection.robot_names) except (KeyError, ValueError) as exc: return _failure(IKStatus.NO_SOLUTION, str(exc)) - if len(pose_group_ids) != 1: + if len(pose_groups) != 1: return _failure( IKStatus.NO_SOLUTION, "PinkIK supports exactly one pose target per request", ) - target_group = next(group for group in resolved_groups if group.id == pose_group_ids[0]) + target_group = pose_groups[0] if not target_group.has_pose_target or target_group.tip_link is None: return _failure( IKStatus.NO_SOLUTION, f"Planning group '{target_group.id}' has no pose target frame", ) - robot_ids = {group.robot_id for group in resolved_groups} + robot_ids = {robot_ids_by_name[group.robot_name] for group in selection.groups} if len(robot_ids) != 1: return _failure(IKStatus.NO_SOLUTION, "PinkIK does not support cross-robot pose IK") - robot_id = target_group.robot_id + robot_id = robot_ids_by_name[target_group.robot_name] if seed is None: with world.scratch_context() as ctx: seed = world.get_joint_state(ctx, robot_id) @@ -264,7 +259,7 @@ def solve_pose_targets( selected_joint_names: list[str] = [] selected_local_names: list[str] = [] - for group in resolved_groups: + for group in selection.groups: selected_joint_names.extend(group.joint_names) selected_local_names.extend(group.local_joint_names) try: @@ -624,4 +619,22 @@ def _collision_failure(result: IKResult) -> IKResult: ) +def _robot_ids_by_name( + world: WorldSpec, robot_names: tuple[RobotName, ...] +) -> dict[RobotName, WorldRobotID]: + robot_ids_by_name: dict[RobotName, WorldRobotID] = {} + for robot_name in robot_names: + matches = [ + robot_id + for robot_id in world.get_robot_ids() + if world.get_robot_config(robot_id).name == robot_name + ] + if not matches: + raise KeyError(f"Robot '{robot_name}' not found") + if len(matches) > 1: + raise ValueError(f"Robot name '{robot_name}' is not unique in planning world") + robot_ids_by_name[robot_name] = matches[0] + return robot_ids_by_name + + __all__ = ["PinkIK", "PinkIKConfig", "PinkIKDependencyError"] diff --git a/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py b/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py index fd9b2a9e54..8eedeb5d4d 100644 --- a/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py +++ b/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py @@ -17,11 +17,14 @@ from __future__ import annotations from collections.abc import Mapping +from pathlib import Path from typing import cast +from dimos.manipulation.planning.groups import PlanningGroup from dimos.manipulation.planning.kinematics.jacobian_ik import JacobianIK +from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus -from dimos.manipulation.planning.spec.models import IKResult, ResolvedPlanningGroup +from dimos.manipulation.planning.spec.models import IKResult from dimos.manipulation.planning.spec.protocols import WorldSpec from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState @@ -37,10 +40,9 @@ def _joint_state(names: list[str], positions: list[float]) -> JointState: def _group( group_id: str, joint_names: tuple[str, ...], tip_link: str | None = "tool0" -) -> ResolvedPlanningGroup: - return ResolvedPlanningGroup( +) -> PlanningGroup: + return PlanningGroup( id=group_id, - robot_id="robot_1", robot_name="arm", group_name=group_id.split("/", maxsplit=1)[1], joint_names=joint_names, @@ -51,13 +53,23 @@ def _group( class _IKWorld: - def __init__(self, groups: Mapping[str, ResolvedPlanningGroup]) -> None: + def __init__(self, groups: Mapping[str, PlanningGroup]) -> None: self._groups = groups + self._robot_configs = { + "robot_1": RobotModelConfig( + name="arm", + model_path=Path("robot.urdf"), + base_pose=_pose(), + joint_names=["joint1", "joint2", "gripper"], + end_effector_link="tool0", + ) + } + + def get_robot_ids(self) -> list[str]: + return list(self._robot_configs) - def resolve_planning_groups( - self, group_ids: tuple[str, ...] - ) -> tuple[ResolvedPlanningGroup, ...]: - return tuple(self._groups[group_id] for group_id in group_ids) + def get_robot_config(self, robot_id: str) -> RobotModelConfig: + return self._robot_configs[robot_id] class _SuccessfulIK(JacobianIK): @@ -90,7 +102,7 @@ def test_solve_pose_targets_filters_result_to_single_group_joints() -> None: result = _SuccessfulIK().solve_pose_targets( world=cast("WorldSpec", world), - pose_targets={"arm/arm": _pose()}, + pose_targets={world._groups["arm/arm"]: _pose()}, seed=_joint_state(["arm/joint1", "arm/joint2", "arm/gripper"], [0.0, 0.0, 0.0]), ) @@ -105,8 +117,8 @@ def test_solve_pose_targets_rejects_auxiliary_groups() -> None: result = _SuccessfulIK().solve_pose_targets( world=cast("WorldSpec", world), - pose_targets={"arm/arm": _pose()}, - auxiliary_groups=["arm/gripper"], + pose_targets={world._groups["arm/arm"]: _pose()}, + auxiliary_groups=[_group("arm/gripper", ("arm/gripper",))], seed=_joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]), ) @@ -119,7 +131,7 @@ def test_solve_pose_targets_rejects_group_without_pose_target_frame() -> None: result = JacobianIK().solve_pose_targets( world=cast("WorldSpec", world), - pose_targets={"arm/gripper": _pose()}, + pose_targets={world._groups["arm/gripper"]: _pose()}, ) assert result.status == IKStatus.NO_SOLUTION diff --git a/dimos/manipulation/planning/kinematics/test_pink_ik.py b/dimos/manipulation/planning/kinematics/test_pink_ik.py index dd644f8fb5..b855562fdb 100644 --- a/dimos/manipulation/planning/kinematics/test_pink_ik.py +++ b/dimos/manipulation/planning/kinematics/test_pink_ik.py @@ -25,6 +25,7 @@ import pytest from dimos.manipulation.planning.factory import create_kinematics +from dimos.manipulation.planning.groups import PlanningGroup from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.kinematics.pink_ik import ( PinkIK, @@ -37,7 +38,7 @@ ) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus -from dimos.manipulation.planning.spec.models import IKResult, ResolvedPlanningGroup +from dimos.manipulation.planning.spec.models import IKResult from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -198,6 +199,29 @@ class _FakeWorld: def __init__(self, collision_free: bool = True) -> None: self.config = _robot_config() self.collision_free = collision_free + self.groups = { + "arm/wrist": PlanningGroup( + id="arm/wrist", + robot_name="arm", + group_name="wrist", + joint_names=("arm/joint_a", "arm/joint_b"), + local_joint_names=("joint_a", "joint_b"), + base_link="base", + tip_link="wrist_tool", + ), + "arm/gripper": PlanningGroup( + id="arm/gripper", + robot_name="arm", + group_name="gripper", + joint_names=("arm/joint_c",), + local_joint_names=("joint_c",), + base_link="base", + tip_link=None, + ), + } + + def get_robot_ids(self) -> list[str]: + return ["robot"] def get_robot_config(self, robot_id: str) -> RobotModelConfig: return self.config @@ -217,33 +241,6 @@ def get_joint_limits(self, robot_id: str) -> tuple[np.ndarray, np.ndarray]: def check_config_collision_free(self, robot_id: str, joint_state: JointState) -> bool: return self.collision_free - def resolve_planning_groups( - self, group_ids: tuple[str, ...] - ) -> tuple[ResolvedPlanningGroup, ...]: - groups = { - "arm/wrist": ResolvedPlanningGroup( - id="arm/wrist", - robot_id="robot", - robot_name="arm", - group_name="wrist", - joint_names=("arm/joint_a", "arm/joint_b"), - local_joint_names=("joint_a", "joint_b"), - base_link="base", - tip_link="wrist_tool", - ), - "arm/gripper": ResolvedPlanningGroup( - id="arm/gripper", - robot_id="robot", - robot_name="arm", - group_name="gripper", - joint_names=("arm/joint_c",), - local_joint_names=("joint_c",), - base_link="base", - tip_link=None, - ), - } - return tuple(groups[group_id] for group_id in group_ids) - def test_create_kinematics_pink_missing_dependency_is_actionable( monkeypatch: pytest.MonkeyPatch, @@ -419,15 +416,16 @@ def fake_solve_single(**kwargs: object) -> IKResult: monkeypatch.setattr(ik, "_solve_single", fake_solve_single) + world = _FakeWorld(collision_free=True) result = ik.solve_pose_targets( - world=cast("Any", _FakeWorld(collision_free=True)), + world=cast("Any", world), pose_targets={ - "arm/wrist": PoseStamped( + world.groups["arm/wrist"]: PoseStamped( position=Vector3(0.1, 0.0, 0.0), orientation=Quaternion(0.0, 0.0, 0.0, 1.0), ) }, - auxiliary_groups=["arm/gripper"], + auxiliary_groups=[world.groups["arm/gripper"]], seed=JointState( {"name": ["arm/joint_a", "arm/joint_b", "arm/joint_c"], "position": [0.0, 0.0, 0.0]} ), diff --git a/dimos/manipulation/planning/monitor/world_monitor.py b/dimos/manipulation/planning/monitor/world_monitor.py index 80e24f2bef..e36d5f55fb 100644 --- a/dimos/manipulation/planning/monitor/world_monitor.py +++ b/dimos/manipulation/planning/monitor/world_monitor.py @@ -22,6 +22,7 @@ from dimos.constants import DEFAULT_THREAD_JOIN_TIMEOUT from dimos.manipulation.planning.factory import create_world +from dimos.manipulation.planning.groups import PlanningGroupRegistry from dimos.manipulation.planning.monitor.robot_state_monitor import RobotStateMonitor from dimos.manipulation.planning.monitor.world_obstacle_monitor import WorldObstacleMonitor from dimos.manipulation.planning.spec.protocols import VisualizationSpec @@ -68,6 +69,8 @@ def __init__( ) self._lock = threading.RLock() self._robot_joints: dict[WorldRobotID, list[str]] = {} + self._robot_ids_by_name: dict[str, WorldRobotID] = {} + self._planning_groups = PlanningGroupRegistry() self._state_monitors: dict[WorldRobotID, RobotStateMonitor] = {} self._obstacle_monitor: WorldObstacleMonitor | None = None self._viz_thread: threading.Thread | None = None @@ -81,9 +84,18 @@ def add_robot(self, config: RobotModelConfig) -> WorldRobotID: with self._lock: robot_id = self._world.add_robot(config) self._robot_joints[robot_id] = config.joint_names + if config.name in self._robot_ids_by_name: + raise ValueError(f"Robot name '{config.name}' is already registered") + self._robot_ids_by_name[config.name] = robot_id + self._planning_groups.add_robot(config) logger.info(f"Added robot '{config.name}' as '{robot_id}'") return robot_id + @property + def planning_groups(self) -> PlanningGroupRegistry: + """Backend-independent planning-group registry for added robots.""" + return self._planning_groups + def get_robot_ids(self) -> list[WorldRobotID]: """Get all robot IDs.""" with self._lock: @@ -364,12 +376,12 @@ def get_group_pose( self, group_id: PlanningGroupID, joint_state: JointState | None = None ) -> PoseStamped: """Get planning group target-frame pose using current state by default.""" - group = self._world.resolve_planning_groups((group_id,))[0] + robot_id = self._robot_id_for_group(group_id) with self._world.scratch_context() as ctx: if joint_state is None: - joint_state = self.get_current_joint_state(group.robot_id) + joint_state = self.get_current_joint_state(robot_id) if joint_state is not None: - self._world.set_joint_state(ctx, group.robot_id, joint_state) + self._world.set_joint_state(ctx, robot_id, joint_state) return self._world.get_group_pose(ctx, group_id) @@ -414,17 +426,18 @@ def get_group_jacobian( self, group_id: PlanningGroupID, joint_state: JointState ) -> NDArray[np.float64]: """Get planning group target-frame 6xN Jacobian matrix.""" - group = self._world.resolve_planning_groups((group_id,))[0] + self._planning_groups.get(group_id) + robot_id = self._robot_id_for_group(group_id) with self._world.scratch_context() as ctx: - self._world.set_joint_state(ctx, group.robot_id, joint_state) + self._world.set_joint_state(ctx, robot_id, joint_state) return self._world.get_group_jacobian(ctx, group_id) def _unique_pose_group_id_for_robot(self, robot_id: WorldRobotID) -> PlanningGroupID: robot_name = self._world.get_robot_config(robot_id).name pose_group_ids = [ group.id - for group in self._world.list_planning_groups() - if group.robot_name == robot_name and group.has_pose_target + for group in self._planning_groups.groups_for_robot(robot_name) + if group.has_pose_target ] if len(pose_group_ids) != 1: raise ValueError( @@ -433,6 +446,15 @@ def _unique_pose_group_id_for_robot(self, robot_id: WorldRobotID) -> PlanningGro ) return pose_group_ids[0] + def _robot_id_for_group(self, group_id: PlanningGroupID) -> WorldRobotID: + group = self._planning_groups.get(group_id) + try: + return self._robot_ids_by_name[group.robot_name] + except KeyError as exc: + raise KeyError( + f"Robot '{group.robot_name}' not found for planning group {group_id}" + ) from exc + # Lifecycle def finalize(self) -> None: diff --git a/dimos/manipulation/planning/planners/rrt_planner.py b/dimos/manipulation/planning/planners/rrt_planner.py index 6fb00f75b9..28f01f4b78 100644 --- a/dimos/manipulation/planning/planners/rrt_planner.py +++ b/dimos/manipulation/planning/planners/rrt_planner.py @@ -26,6 +26,7 @@ import numpy as np +from dimos.manipulation.planning.groups import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.planning_identifiers import ( local_joint_name_from_global, make_global_joint_names, @@ -33,9 +34,8 @@ from dimos.manipulation.planning.spec.enums import PlanningStatus from dimos.manipulation.planning.spec.models import ( JointPath, - PlanningGroupID, PlanningResult, - ResolvedPlanningGroup, + RobotName, WorldRobotID, ) from dimos.manipulation.planning.spec.protocols import WorldSpec @@ -167,19 +167,14 @@ def get_name(self) -> str: def plan_selected_joint_path( self, world: WorldSpec, - group_ids: list[PlanningGroupID] | tuple[PlanningGroupID, ...], + selection: PlanningGroupSelection, start: JointState, goal: JointState, timeout: float = 10.0, ) -> PlanningResult: """Plan a collision-free path for an explicit planning-group selection.""" - try: - resolved_groups = world.resolve_planning_groups(tuple(group_ids)) - except (KeyError, ValueError) as exc: - return _create_failure_result(PlanningStatus.INVALID_GOAL, str(exc)) - selected_joint_names = [ - joint_name for group in resolved_groups for joint_name in group.joint_names + joint_name for group in selection.groups for joint_name in group.joint_names ] exact_error = _validate_exact_joint_keys(start, selected_joint_names, "start") if exact_error is not None: @@ -188,11 +183,17 @@ def plan_selected_joint_path( if exact_error is not None: return exact_error - robot_ids = {group.robot_id for group in resolved_groups} + try: + robot_ids_by_name = _robot_ids_by_name(world, selection.robot_names) + except (KeyError, ValueError) as exc: + return _create_failure_result(PlanningStatus.INVALID_GOAL, str(exc)) + + robot_ids = set(robot_ids_by_name.values()) if len(robot_ids) != 1: return self._plan_multi_robot_selected_joint_path( world=world, - resolved_groups=resolved_groups, + groups=selection.groups, + robot_ids_by_name=robot_ids_by_name, start=start, goal=goal, timeout=timeout, @@ -244,7 +245,8 @@ def plan_selected_joint_path( def _plan_multi_robot_selected_joint_path( self, world: WorldSpec, - resolved_groups: tuple[ResolvedPlanningGroup, ...], + groups: tuple[PlanningGroup, ...], + robot_ids_by_name: dict[RobotName, WorldRobotID], start: JointState, goal: JointState, timeout: float, @@ -258,14 +260,16 @@ def _plan_multi_robot_selected_joint_path( "World must be finalized before planning", ) - selected_joint_names = [joint for group in resolved_groups for joint in group.joint_names] + selected_joint_names = [joint for group in groups for joint in group.joint_names] q_start = np.array( _order_joint_state(start, selected_joint_names).position, dtype=np.float64 ) q_goal = np.array(_order_joint_state(goal, selected_joint_names).position, dtype=np.float64) try: - robot_order, robot_joint_names = _validate_full_robot_groups(world, resolved_groups) + robot_order, robot_joint_names = _validate_full_robot_groups( + world, groups, robot_ids_by_name + ) except KeyError as exc: return _create_failure_result(PlanningStatus.NO_SOLUTION, str(exc)) if not robot_order: @@ -641,21 +645,41 @@ def _create_failure_result( def _validate_full_robot_groups( world: WorldSpec, - resolved_groups: tuple[ResolvedPlanningGroup, ...], + groups: tuple[PlanningGroup, ...], + robot_ids_by_name: dict[RobotName, WorldRobotID], ) -> tuple[list[WorldRobotID], dict[WorldRobotID, list[str]]]: robot_order: list[WorldRobotID] = [] robot_joint_names: dict[WorldRobotID, list[str]] = {} known_robot_ids = set(world.get_robot_ids()) - for group in resolved_groups: - if group.robot_id not in known_robot_ids: - raise KeyError(f"Robot '{group.robot_id}' not found") - if group.robot_id not in robot_joint_names: - robot_joint_names[group.robot_id] = [] - robot_order.append(group.robot_id) - robot_joint_names[group.robot_id].extend(group.joint_names) + for group in groups: + robot_id = robot_ids_by_name[group.robot_name] + if robot_id not in known_robot_ids: + raise KeyError(f"Robot '{robot_id}' not found") + if robot_id not in robot_joint_names: + robot_joint_names[robot_id] = [] + robot_order.append(robot_id) + robot_joint_names[robot_id].extend(group.joint_names) return robot_order, robot_joint_names +def _robot_ids_by_name( + world: WorldSpec, robot_names: tuple[RobotName, ...] +) -> dict[RobotName, WorldRobotID]: + robot_ids_by_name: dict[RobotName, WorldRobotID] = {} + for robot_name in robot_names: + matches = [ + robot_id + for robot_id in world.get_robot_ids() + if world.get_robot_config(robot_id).name == robot_name + ] + if not matches: + raise KeyError(f"Robot '{robot_name}' not found") + if len(matches) > 1: + raise ValueError(f"Robot name '{robot_name}' is not unique in planning world") + robot_ids_by_name[robot_name] = matches[0] + return robot_ids_by_name + + def _validate_selected_groups_cover_full_robots( world: WorldSpec, robot_order: list[WorldRobotID], diff --git a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py index 3c91c719dd..6d4dd20c9e 100644 --- a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py +++ b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py @@ -23,10 +23,10 @@ import numpy as np +from dimos.manipulation.planning.groups import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.planners.rrt_planner import RRTConnectPlanner from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import PlanningStatus -from dimos.manipulation.planning.spec.models import ResolvedPlanningGroup from dimos.manipulation.planning.spec.protocols import WorldSpec from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState @@ -52,13 +52,11 @@ def _joint_state(names: list[str], positions: list[float]) -> JointState: def _group( group_id: str, - robot_id: str, robot_name: str, joint_names: tuple[str, ...], -) -> ResolvedPlanningGroup: - return ResolvedPlanningGroup( +) -> PlanningGroup: + return PlanningGroup( id=group_id, - robot_id=robot_id, robot_name=robot_name, group_name=group_id.split("/", maxsplit=1)[1], joint_names=joint_names, @@ -68,27 +66,24 @@ def _group( ) +def _selection(*groups: PlanningGroup) -> PlanningGroupSelection: + return PlanningGroupSelection.from_groups(tuple(groups)) + + class _SelectionWorld: is_finalized = True def __init__( self, - groups: dict[str, ResolvedPlanningGroup], robot_configs: dict[str, RobotModelConfig], coupled_collision_predicate: Callable[[dict[str, JointState]], bool] | None = None, ) -> None: - self._groups = groups self._robot_configs = robot_configs self._coupled_collision_predicate = coupled_collision_predicate self.coupled_collision_checks = 0 self.config_collision_names: list[list[str]] = [] self.edge_collision_names: list[tuple[list[str], list[str]]] = [] - def resolve_planning_groups( - self, group_ids: tuple[str, ...] - ) -> tuple[ResolvedPlanningGroup, ...]: - return tuple(self._groups[group_id] for group_id in group_ids) - def get_robot_config(self, robot_id: str) -> RobotModelConfig: return self._robot_configs[robot_id] @@ -130,14 +125,12 @@ def is_collision_free(self, ctx: dict[str, JointState], robot_id: str) -> bool: def test_plan_selected_joint_path_rejects_missing_and_extra_start_names() -> None: - world = _SelectionWorld( - groups={"arm/arm": _group("arm/arm", "robot_1", "arm", ("arm/joint1", "arm/joint2"))}, - robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}, - ) + group = _group("arm/arm", "arm", ("arm/joint1", "arm/joint2")) + world = _SelectionWorld(robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}) result = RRTConnectPlanner().plan_selected_joint_path( cast("WorldSpec", world), - ["arm/arm"], + _selection(group), start=_joint_state(["arm/joint1", "arm/extra"], [0.0, 0.0]), goal=_joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]), ) @@ -148,14 +141,12 @@ def test_plan_selected_joint_path_rejects_missing_and_extra_start_names() -> Non def test_plan_selected_joint_path_rejects_missing_and_extra_goal_names() -> None: - world = _SelectionWorld( - groups={"arm/arm": _group("arm/arm", "robot_1", "arm", ("arm/joint1", "arm/joint2"))}, - robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}, - ) + group = _group("arm/arm", "arm", ("arm/joint1", "arm/joint2")) + world = _SelectionWorld(robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}) result = RRTConnectPlanner().plan_selected_joint_path( cast("WorldSpec", world), - ["arm/arm"], + _selection(group), start=_joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]), goal=_joint_state(["arm/joint1", "arm/extra"], [0.0, 0.0]), ) @@ -166,11 +157,9 @@ def test_plan_selected_joint_path_rejects_missing_and_extra_goal_names() -> None def test_plan_selected_joint_path_plans_cross_robot_full_group_selection() -> None: + left_group = _group("left/arm", "left", ("left/joint1",)) + right_group = _group("right/arm", "right", ("right/joint1",)) world = _SelectionWorld( - groups={ - "left/arm": _group("left/arm", "left_robot", "left", ("left/joint1",)), - "right/arm": _group("right/arm", "right_robot", "right", ("right/joint1",)), - }, robot_configs={ "left_robot": _robot_config("left", ["joint1"]), "right_robot": _robot_config("right", ["joint1"]), @@ -180,7 +169,7 @@ def test_plan_selected_joint_path_plans_cross_robot_full_group_selection() -> No result = RRTConnectPlanner().plan_selected_joint_path( cast("WorldSpec", world), - ["left/arm", "right/arm"], + _selection(left_group, right_group), start=joint_state, goal=_joint_state(["left/joint1", "right/joint1"], [0.1, -0.1]), ) @@ -193,21 +182,12 @@ def test_plan_selected_joint_path_plans_cross_robot_full_group_selection() -> No def test_plan_selected_joint_path_converts_single_robot_backend_boundary_to_local() -> None: - world = _SelectionWorld( - groups={ - "arm/manipulator": _group( - "arm/manipulator", - "robot_1", - "arm", - ("arm/joint1", "arm/joint2"), - ) - }, - robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}, - ) + group = _group("arm/manipulator", "arm", ("arm/joint1", "arm/joint2")) + world = _SelectionWorld(robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}) result = RRTConnectPlanner().plan_selected_joint_path( cast("WorldSpec", world), - ["arm/manipulator"], + _selection(group), start=_joint_state(["arm/joint2", "arm/joint1"], [0.2, 0.1]), goal=_joint_state(["arm/joint1", "arm/joint2"], [0.3, 0.4]), ) @@ -231,11 +211,9 @@ def coupled_free(ctx: dict[str, JointState]) -> bool: right = ctx["right_robot"].position[0] return not (left > 0.04 and right > 0.04) + left_group = _group("left/arm", "left", ("left/joint1",)) + right_group = _group("right/arm", "right", ("right/joint1",)) world = _SelectionWorld( - groups={ - "left/arm": _group("left/arm", "left_robot", "left", ("left/joint1",)), - "right/arm": _group("right/arm", "right_robot", "right", ("right/joint1",)), - }, robot_configs={ "left_robot": _robot_config("left", ["joint1"]), "right_robot": _robot_config("right", ["joint1"]), @@ -245,7 +223,7 @@ def coupled_free(ctx: dict[str, JointState]) -> bool: result = RRTConnectPlanner().plan_selected_joint_path( cast("WorldSpec", world), - ["left/arm", "right/arm"], + _selection(left_group, right_group), start=_joint_state(["left/joint1", "right/joint1"], [0.0, 0.0]), goal=_joint_state(["left/joint1", "right/joint1"], [0.1, 0.1]), ) @@ -255,15 +233,13 @@ def coupled_free(ctx: dict[str, JointState]) -> bool: def test_plan_selected_joint_path_rejects_single_robot_subset_selection() -> None: - world = _SelectionWorld( - groups={"arm/wrist": _group("arm/wrist", "robot_1", "arm", ("arm/joint2",))}, - robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}, - ) + group = _group("arm/wrist", "arm", ("arm/joint2",)) + world = _SelectionWorld(robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}) joint_state = _joint_state(["arm/joint2"], [0.0]) result = RRTConnectPlanner().plan_selected_joint_path( cast("WorldSpec", world), - ["arm/wrist"], + _selection(group), start=joint_state, goal=joint_state, ) diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index 18209cad24..32f32f5818 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -21,12 +21,14 @@ from pydantic import Field from dimos.core.module import ModuleConfig -from dimos.manipulation.planning.planning_groups import FALLBACK_PLANNING_GROUP_NAME +from dimos.manipulation.planning.groups import ( + FALLBACK_PLANNING_GROUP_NAME, + PlanningGroupDefinition, +) from dimos.manipulation.planning.planning_identifiers import ( assert_local_joint_names, assert_valid_robot_name, ) -from dimos.manipulation.planning.spec.models import PlanningGroupDefinition from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped diff --git a/dimos/manipulation/planning/spec/models.py b/dimos/manipulation/planning/spec/models.py index d7a226d325..cafcec37ce 100644 --- a/dimos/manipulation/planning/spec/models.py +++ b/dimos/manipulation/planning/spec/models.py @@ -17,7 +17,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal, TypeAlias +from typing import TYPE_CHECKING, TypeAlias from dimos.manipulation.planning.spec.enums import ( IKStatus, @@ -55,79 +55,6 @@ Jacobian: TypeAlias = "NDArray[np.float64]" """6 x n Jacobian matrix (rows: [vx, vy, vz, wx, wy, wz])""" -PlanningGroupSource: TypeAlias = Literal["srdf", "fallback"] - - -@dataclass(frozen=True) -class PlanningGroupDefinition: - """Model-level declaration of a planning group. - - Joint names are local model names. The definition is not bound to a world - robot ID and is safe to store on RobotModelConfig. Definitions are parsed - from SRDF or conservative fallback generation before any robot instance is - added to a planning world. - """ - - name: str - joint_names: tuple[LocalModelJointName, ...] - base_link: str - tip_link: str | None = None - source: PlanningGroupSource = "srdf" - - @property - def has_pose_target(self) -> bool: - """Whether this group has a valid pose target frame.""" - return self.tip_link is not None - - -@dataclass(frozen=True) -class PlanningGroupDescriptor: - """Read-only public snapshot for an available planning group. - - Descriptors are returned by query APIs. They expose stable public IDs and - global joint names for callers, but intentionally do not expose backend - runtime IDs or mutable world state. - """ - - id: PlanningGroupID - robot_name: RobotName - group_name: str - joint_names: tuple[GlobalJointName, ...] - local_joint_names: tuple[LocalModelJointName, ...] - base_link: str - tip_link: str | None = None - source: PlanningGroupSource = "srdf" - - @property - def has_pose_target(self) -> bool: - """Whether this group can be directly pose-targeted.""" - return self.tip_link is not None - - -@dataclass(frozen=True) -class ResolvedPlanningGroup: - """Runtime/world-bound planning group data. - - Resolved groups are created from descriptors/IDs for a specific planning - world. They include the internal WorldRobotID and are the form consumed by - planners, IK backends, and group-scoped FK/Jacobian calls. - """ - - id: PlanningGroupID - robot_id: WorldRobotID - robot_name: RobotName - group_name: str - joint_names: tuple[GlobalJointName, ...] - local_joint_names: tuple[LocalModelJointName, ...] - base_link: str - tip_link: str | None = None - source: PlanningGroupSource = "srdf" - - @property - def has_pose_target(self) -> bool: - """Whether this group can be directly pose-targeted.""" - return self.tip_link is not None - @dataclass class GeneratedPlan: diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index bb761088a4..4df2ccce54 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -29,15 +29,14 @@ import numpy as np from numpy.typing import NDArray + from dimos.manipulation.planning.groups import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.models import ( GeneratedPlan, IKResult, Obstacle, - PlanningGroupDescriptor, PlanningGroupID, PlanningResult, - ResolvedPlanningGroup, WorldRobotID, ) from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -75,26 +74,6 @@ def get_robot_config(self, robot_id: WorldRobotID) -> RobotModelConfig: """Get robot configuration.""" ... - def list_planning_groups(self) -> tuple[PlanningGroupDescriptor, ...]: - """List planning groups for robots currently added to this world. - - SRDF/fallback parsing creates model-level definitions before world - binding. This query returns world-level descriptor snapshots with stable - public IDs and global joint names. - """ - ... - - def resolve_planning_groups( - self, group_ids: list[PlanningGroupID] | tuple[PlanningGroupID, ...] - ) -> tuple[ResolvedPlanningGroup, ...]: - """Resolve group IDs against this world's runtime robot bindings. - - Resolution is world-bound: it looks up the robots added to this world, - attaches WorldRobotID, validates selected groups, and returns data that - backend planners/IK can use with world contexts. - """ - ... - def get_joint_limits( self, robot_id: WorldRobotID ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: # lower limits, upper limits @@ -259,9 +238,8 @@ def solve( def solve_pose_targets( self, world: WorldSpec, - pose_targets: dict[PlanningGroupID | PlanningGroupDescriptor, PoseStamped], - auxiliary_groups: list[PlanningGroupID | PlanningGroupDescriptor] - | tuple[PlanningGroupID | PlanningGroupDescriptor, ...] = (), + pose_targets: dict[PlanningGroup, PoseStamped], + auxiliary_groups: list[PlanningGroup] | tuple[PlanningGroup, ...] = (), seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, @@ -299,7 +277,7 @@ def plan_joint_path( def plan_selected_joint_path( self, world: WorldSpec, - group_ids: list[PlanningGroupID] | tuple[PlanningGroupID, ...], + selection: PlanningGroupSelection, start: JointState, goal: JointState, timeout: float = 10.0, diff --git a/dimos/manipulation/planning/test_planning_group_utils.py b/dimos/manipulation/planning/test_planning_group_utils.py index dacfcf53a2..1186372ae0 100644 --- a/dimos/manipulation/planning/test_planning_group_utils.py +++ b/dimos/manipulation/planning/test_planning_group_utils.py @@ -18,15 +18,13 @@ import pytest -from dimos.manipulation.planning.planning_group_utils import joint_target_to_global_names -from dimos.manipulation.planning.spec.models import ResolvedPlanningGroup +from dimos.manipulation.planning.groups import PlanningGroup, joint_target_to_global_names from dimos.msgs.sensor_msgs.JointState import JointState -def _make_group() -> ResolvedPlanningGroup: - return ResolvedPlanningGroup( +def _make_group() -> PlanningGroup: + return PlanningGroup( id="left/arm", - robot_id="robot_left", robot_name="left", group_name="arm", joint_names=("left/j1", "left/j2", "left/j3"), diff --git a/dimos/manipulation/planning/test_planning_groups.py b/dimos/manipulation/planning/test_planning_groups.py index beb6984851..303c469e67 100644 --- a/dimos/manipulation/planning/test_planning_groups.py +++ b/dimos/manipulation/planning/test_planning_groups.py @@ -20,7 +20,7 @@ import pytest -from dimos.manipulation.planning.planning_groups import ( +from dimos.manipulation.planning.groups import ( FALLBACK_PLANNING_GROUP_NAME, PlanningGroupDiscoveryError, discover_planning_group_definitions, diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 19df494c5e..a884616b47 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -25,21 +25,17 @@ import numpy as np +from dimos.manipulation.planning.groups import PlanningGroupRegistry from dimos.manipulation.planning.planning_identifiers import ( assert_local_joint_names, make_global_joint_name, - make_global_joint_names, - make_planning_group_id, - parse_planning_group_id, ) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import ObstacleType from dimos.manipulation.planning.spec.models import ( GeneratedPlan, Obstacle, - PlanningGroupDescriptor, PlanningGroupID, - ResolvedPlanningGroup, WorldRobotID, ) from dimos.manipulation.planning.spec.protocols import VisualizationSpec, WorldSpec @@ -209,6 +205,7 @@ def __init__(self, time_step: float = 0.0, enable_viz: bool = False) -> None: # Tracking data self._robots: dict[WorldRobotID, _RobotData] = {} + self._planning_groups = PlanningGroupRegistry() self._obstacles: dict[str, _ObstacleData] = {} self._robot_counter = 0 self._obstacle_counter = 0 @@ -261,6 +258,7 @@ def add_robot(self, config: RobotModelConfig) -> WorldRobotID: base_frame=base_frame, preview_model_instance=preview_model_instance, ) + self._planning_groups.add_robot(config) logger.info(f"Added robot '{robot_id}' ({config.name})") return robot_id @@ -349,75 +347,6 @@ def get_robot_config(self, robot_id: WorldRobotID) -> RobotModelConfig: raise KeyError(f"Robot '{robot_id}' not found") return self._robots[robot_id].config - def list_planning_groups(self) -> tuple[PlanningGroupDescriptor, ...]: - """List available planning groups as immutable descriptor snapshots.""" - descriptors: list[PlanningGroupDescriptor] = [] - for robot_data in self._robots.values(): - config = robot_data.config - for group in config.planning_groups: - descriptors.append( - PlanningGroupDescriptor( - id=make_planning_group_id(config.name, group.name), - robot_name=config.name, - group_name=group.name, - joint_names=tuple(make_global_joint_names(config.name, group.joint_names)), - local_joint_names=group.joint_names, - base_link=group.base_link, - tip_link=group.tip_link, - source=group.source, - ) - ) - return tuple(descriptors) - - def resolve_planning_groups( - self, group_ids: list[PlanningGroupID] | tuple[PlanningGroupID, ...] - ) -> tuple[ResolvedPlanningGroup, ...]: - """Resolve planning group IDs against current world robot data.""" - resolved_groups: list[ResolvedPlanningGroup] = [] - seen_joints: dict[str, PlanningGroupID] = {} - - for group_id in group_ids: - robot_name, group_name = parse_planning_group_id(group_id) - robot_data = self._get_robot_data_by_name(robot_name) - group = next( - ( - candidate - for candidate in robot_data.config.planning_groups - if candidate.name == group_name - ), - None, - ) - if group is None: - raise KeyError(f"Unknown planning group ID: {group_id}") - - global_joint_names = tuple( - make_global_joint_name(robot_name, local_name) for local_name in group.joint_names - ) - for joint_name in global_joint_names: - previous_group_id = seen_joints.get(joint_name) - if previous_group_id is not None: - raise ValueError( - "Selected planning groups overlap on global joint " - f"{joint_name}: {previous_group_id} and {group_id}" - ) - seen_joints[joint_name] = group_id - - resolved_groups.append( - ResolvedPlanningGroup( - id=group_id, - robot_id=robot_data.robot_id, - robot_name=robot_name, - group_name=group_name, - joint_names=global_joint_names, - local_joint_names=group.joint_names, - base_link=group.base_link, - tip_link=group.tip_link, - source=group.source, - ) - ) - - return tuple(resolved_groups) - def _get_robot_data_by_name(self, robot_name: str) -> _RobotData: matches = [ robot_data @@ -430,10 +359,6 @@ def _get_robot_data_by_name(self, robot_name: str) -> _RobotData: raise ValueError(f"Robot name '{robot_name}' is not unique in planning world") return matches[0] - def _resolve_single_planning_group(self, group_id: PlanningGroupID) -> ResolvedPlanningGroup: - resolved_groups = self.resolve_planning_groups((group_id,)) - return resolved_groups[0] - def get_joint_limits( self, robot_id: WorldRobotID ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: @@ -1062,11 +987,11 @@ def get_group_pose(self, ctx: Context, group_id: PlanningGroupID) -> PoseStamped if not self._finalized: raise RuntimeError("World must be finalized first") - group = self._resolve_single_planning_group(group_id) + group = self._planning_groups.get(group_id) if group.tip_link is None: raise ValueError(f"Planning group '{group_id}' has no pose target frame") - robot_data = self._robots[group.robot_id] + robot_data = self._get_robot_data_by_name(group.robot_name) plant_ctx = self._diagram.GetSubsystemContext(self._plant, ctx) try: @@ -1165,11 +1090,11 @@ def get_group_jacobian(self, ctx: Context, group_id: PlanningGroupID) -> NDArray if not self._finalized: raise RuntimeError("World must be finalized first") - group = self._resolve_single_planning_group(group_id) + group = self._planning_groups.get(group_id) if group.tip_link is None: raise ValueError(f"Planning group '{group_id}' has no pose target frame") - robot_data = self._robots[group.robot_id] + robot_data = self._get_robot_data_by_name(group.robot_name) plant_ctx = self._diagram.GetSubsystemContext(self._plant, ctx) try: @@ -1238,9 +1163,10 @@ def _preview_robot_ids_for_groups( ) -> list[WorldRobotID]: """Resolve planning groups to stable preview robot IDs.""" robot_ids: list[WorldRobotID] = [] - for group in self.resolve_planning_groups(tuple(group_ids)): - if group.robot_id not in robot_ids: - robot_ids.append(group.robot_id) + for group in self._planning_groups.select(tuple(group_ids)).groups: + robot_id = self._get_robot_data_by_name(group.robot_name).robot_id + if robot_id not in robot_ids: + robot_ids.append(robot_id) return robot_ids def _show_preview_robot(self, robot_id: WorldRobotID) -> None: diff --git a/dimos/manipulation/planning/world/test_drake_world_planning_groups.py b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py index adc04aaae6..5a1f8f1ecb 100644 --- a/dimos/manipulation/planning/world/test_drake_world_planning_groups.py +++ b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py @@ -21,8 +21,8 @@ import numpy as np import pytest +from dimos.manipulation.planning.groups import PlanningGroupDefinition, PlanningGroupRegistry from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.manipulation.planning.spec.models import PlanningGroupDefinition from dimos.manipulation.planning.world.drake_world import DrakeWorld, _RobotData from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState @@ -61,6 +61,7 @@ def _world(*configs: RobotModelConfig) -> DrakeWorld: ) for index, config in enumerate(configs, start=1) } + world._planning_groups = PlanningGroupRegistry(configs) return world @@ -74,10 +75,11 @@ def _arm_group(*joint_names: str) -> PlanningGroupDefinition: ) -def test_list_planning_groups_returns_stable_ids_and_global_joint_names() -> None: - world = _world(_config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")])) +def test_planning_group_registry_returns_stable_ids_and_global_joint_names() -> None: + config = _config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")]) + registry = PlanningGroupRegistry([config]) - groups = world.list_planning_groups() + groups = registry.list() assert len(groups) == 1 assert groups[0].id == "left/arm" @@ -94,69 +96,79 @@ def test_robot_model_config_allows_planning_groups_without_robot_scoped_ee() -> joint_names=["joint1"], planning_groups=[_arm_group("joint1")], ) - world = _world(config) + registry = PlanningGroupRegistry([config]) - groups = world.list_planning_groups() + groups = registry.list() assert config.end_effector_link is None assert groups[0].id == "left/arm" def test_duplicate_local_joint_names_across_robots_are_disambiguated() -> None: - world = _world( - _config("left", ["joint1"], [_arm_group("joint1")]), - _config("right", ["joint1"], [_arm_group("joint1")]), + registry = PlanningGroupRegistry( + [ + _config("left", ["joint1"], [_arm_group("joint1")]), + _config("right", ["joint1"], [_arm_group("joint1")]), + ] ) - groups = world.list_planning_groups() + groups = registry.list() assert [group.id for group in groups] == ["left/arm", "right/arm"] assert [group.joint_names for group in groups] == [("left/joint1",), ("right/joint1",)] -def test_resolve_planning_groups_returns_robot_ids_and_joint_names() -> None: - world = _world( - _config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")]), - _config("right", ["joint1", "joint2"], [_arm_group("joint2")]), +def test_planning_group_selection_returns_ordered_global_joint_names() -> None: + registry = PlanningGroupRegistry( + [ + _config("left", ["joint1", "joint2"], [_arm_group("joint1", "joint2")]), + _config("right", ["joint1", "joint2"], [_arm_group("joint2")]), + ] ) - resolved = world.resolve_planning_groups(("left/arm", "right/arm")) + selection = registry.select(("left/arm", "right/arm")) - assert [group.id for group in resolved] == ["left/arm", "right/arm"] - assert [group.robot_id for group in resolved] == ["robot_1", "robot_2"] - assert [group.joint_names for group in resolved] == [ + assert list(selection.group_ids) == ["left/arm", "right/arm"] + assert list(selection.robot_names) == ["left", "right"] + assert [group.joint_names for group in selection.groups] == [ ("left/joint1", "left/joint2"), ("right/joint2",), ] - assert [group.local_joint_names for group in resolved] == [("joint1", "joint2"), ("joint2",)] + assert list(selection.joint_names) == ["left/joint1", "left/joint2", "right/joint2"] + assert [group.local_joint_names for group in selection.groups] == [ + ("joint1", "joint2"), + ("joint2",), + ] -def test_resolve_planning_groups_unknown_group_raises_key_error() -> None: - world = _world(_config("left", ["joint1"], [_arm_group("joint1")])) +def test_planning_group_registry_unknown_group_raises_key_error() -> None: + registry = PlanningGroupRegistry([_config("left", ["joint1"], [_arm_group("joint1")])]) with pytest.raises(KeyError, match="Unknown planning group ID: left/gripper"): - world.resolve_planning_groups(("left/gripper",)) - - -def test_resolve_planning_groups_overlapping_same_robot_groups_raise_value_error() -> None: - world = _world( - _config( - "left", - ["joint1", "joint2"], - [ - _arm_group("joint1", "joint2"), - PlanningGroupDefinition( - name="wrist", - joint_names=("joint2",), - base_link="link1", - tip_link="tool0", - ), - ], - ) + registry.select(("left/gripper",)) + + +def test_planning_group_selection_overlapping_same_robot_groups_raise_value_error() -> None: + registry = PlanningGroupRegistry( + [ + _config( + "left", + ["joint1", "joint2"], + [ + _arm_group("joint1", "joint2"), + PlanningGroupDefinition( + name="wrist", + joint_names=("joint2",), + base_link="link1", + tip_link="tool0", + ), + ], + ) + ] ) with pytest.raises(ValueError, match="overlap.*left/joint2"): - world.resolve_planning_groups(("left/arm", "left/wrist")) + registry.select(("left/arm", "left/wrist")) def test_positions_for_robot_state_accepts_local_joint_names_in_config_order() -> None: diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 9ac5d13918..ce0202a23f 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -27,6 +27,7 @@ ManipulationModuleConfig, ManipulationState, ) +from dimos.manipulation.planning.groups import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor from dimos.manipulation.planning.spec.config import RobotModelConfig @@ -34,7 +35,6 @@ from dimos.manipulation.planning.spec.models import ( GeneratedPlan, IKResult, - ResolvedPlanningGroup, ) from dimos.manipulation.planning.spec.protocols import VisualizationSpec from dimos.msgs.geometry_msgs.Pose import Pose @@ -360,9 +360,9 @@ def test_execute_requires_task_name(self): end_effector_link="ee", ) module = _make_module_with_monitor(config_no_task) - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("arm", "manipulator", ["j1"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("arm", "manipulator", ["j1"])] + ) module._world_monitor.get_current_joint_state.return_value = JointState( name=["j1"], position=[0.0] ) @@ -380,9 +380,9 @@ def test_execute_success(self, robot_config, simple_trajectory): generator = MagicMock() generator.generate.return_value = simple_trajectory module._robots = {"test_arm": ("id", robot_config, generator)} - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("test_arm", "manipulator", ["joint1", "joint2", "joint3"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("test_arm", "manipulator", ["joint1", "joint2", "joint3"])] + ) module._world_monitor.get_current_joint_state.return_value = JointState( name=["joint1", "joint2", "joint3"], position=[0.0, 0.0, 0.0] ) @@ -423,9 +423,9 @@ def test_execute_rejected(self, robot_config, simple_trajectory): generator = MagicMock() generator.generate.return_value = simple_trajectory module._robots = {"test_arm": ("id", robot_config, generator)} - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("test_arm", "manipulator", ["joint1", "joint2", "joint3"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("test_arm", "manipulator", ["joint1", "joint2", "joint3"])] + ) module._world_monitor.get_current_joint_state.return_value = JointState( name=["joint1", "joint2", "joint3"], position=[0.0, 0.0, 0.0] ) @@ -460,6 +460,12 @@ def _make_module_with_monitor(*configs: RobotModelConfig) -> ManipulationModule: for config in configs: robot_id = f"robot_{config.name}" module._robots[config.name] = (robot_id, config, MagicMock()) + module._world_monitor.planning_groups = _FakePlanningGroups( + [ + _make_global_group(config.name, "manipulator", list(config.joint_names)) + for config in configs + ] + ) return module @@ -483,12 +489,9 @@ def _make_robot_config( ) -def _make_global_group( - robot_name: str, group_name: str, joints: list[str] -) -> ResolvedPlanningGroup: - return ResolvedPlanningGroup( +def _make_global_group(robot_name: str, group_name: str, joints: list[str]) -> PlanningGroup: + return PlanningGroup( id=f"{robot_name}/{group_name}", - robot_id=f"robot_{robot_name}", robot_name=robot_name, group_name=group_name, joint_names=tuple(f"{robot_name}/{joint}" for joint in joints), @@ -498,6 +501,32 @@ def _make_global_group( ) +class _FakePlanningGroups: + def __init__(self, groups: list[PlanningGroup]) -> None: + self._groups = {group.id: group for group in groups} + + def get(self, group_id: str) -> PlanningGroup: + return self._groups[group_id] + + def select(self, group_ids: tuple[str, ...]) -> PlanningGroupSelection: + return PlanningGroupSelection.from_groups( + tuple(self._groups[group_id] for group_id in group_ids) + ) + + def groups_for_robot(self, robot_name: str) -> tuple[PlanningGroup, ...]: + return tuple(group for group in self._groups.values() if group.robot_name == robot_name) + + def default_group_id_for_robot(self, robot_name: str) -> str | None: + group_id = f"{robot_name}/manipulator" + return group_id if group_id in self._groups else None + + def primary_pose_group_id_for_robot(self, robot_name: str) -> str | None: + for group in self.groups_for_robot(robot_name): + if group.has_pose_target: + return group.id + return None + + def _make_generated_plan(group_ids: tuple[str, ...], *points: list[float]) -> GeneratedPlan: return GeneratedPlan( group_ids=group_ids, @@ -741,9 +770,9 @@ def test_preview_plan_explicit_duration_overrides_default(self): def test_preview_plan_respects_robot_filter(self): module = _make_module() module._world_monitor = MagicMock() - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("arm", "manipulator", ["j1"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("arm", "manipulator", ["j1"])] + ) module._last_plan = GeneratedPlan( group_ids=("arm/manipulator",), path=[JointState(name=["arm/j1"], position=[0.0])], @@ -757,9 +786,9 @@ def test_preview_plan_respects_robot_filter(self): def test_preview_plan_rejects_unaffected_robot_filter(self): module = _make_module() module._world_monitor = MagicMock() - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("arm", "manipulator", ["j1"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("arm", "manipulator", ["j1"])] + ) module._last_plan = GeneratedPlan( group_ids=("arm/manipulator",), path=[JointState(name=["arm/j1"], position=[0.0])], @@ -783,9 +812,9 @@ class TestGeneratedPlanProjection: def test_selected_joint_state_accepts_local_current_state_names(self): config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("left", "arm", ["j1", "j2"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("left", "arm", ["j1", "j2"])] + ) module._world_monitor.get_current_joint_state.return_value = JointState( name=["j1", "j2"], position=[1.0, 2.0] ) @@ -799,9 +828,9 @@ def test_selected_joint_state_accepts_local_current_state_names(self): def test_selected_joint_state_rejects_mixed_current_state_names(self): config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("left", "arm", ["j1", "j2"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("left", "arm", ["j1", "j2"])] + ) module._world_monitor.get_current_joint_state.return_value = JointState( name=["left/j1", "j2"], position=[1.0, 2.0] ) @@ -820,10 +849,12 @@ def test_execute_plan_dispatches_one_trajectory_per_affected_robot(self): right_gen = _trajectory_generator() module._robots["left"] = ("robot_left", left_config, left_gen) module._robots["right"] = ("robot_right", right_config, right_gen) - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("left", "arm", ["j1", "j2"]), - _make_global_group("right", "arm", ["j1"]), - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [ + _make_global_group("left", "arm", ["j1", "j2"]), + _make_global_group("right", "arm", ["j1"]), + ] + ) module._world_monitor.get_current_joint_state.side_effect = [ JointState(name=["j1", "j2", "j3"], position=[0.0, 0.0, 9.0]), JointState(name=["j1", "j2"], position=[0.0, 8.0]), @@ -853,9 +884,9 @@ def test_execute_plan_holds_non_selected_joints_from_current_state(self): module = _make_module_with_monitor(config) generator = _trajectory_generator() module._robots["left"] = ("robot_left", config, generator) - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("left", "arm", ["j2"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("left", "arm", ["j2"])] + ) module._world_monitor.get_current_joint_state.return_value = JointState( name=["j1", "j2", "j3"], position=[10.0, 20.0, 30.0] ) @@ -882,9 +913,9 @@ def test_execute_plan_holds_non_selected_joints_from_current_state(self): def test_execute_plan_rejects_local_waypoint_names(self): config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("left", "arm", ["j1"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("left", "arm", ["j1"])] + ) module._world_monitor.get_current_joint_state.return_value = JointState( name=["j1", "j2"], position=[10.0, 20.0] ) @@ -901,9 +932,9 @@ def test_execute_plan_rejects_local_waypoint_names(self): def test_preview_plan_with_last_plan_animates_generated_plan(self): config = _make_robot_config("left", ["j1", "j2"], "task") module = _make_module_with_monitor(config) - module._world_monitor.world.resolve_planning_groups.return_value = [ - _make_global_group("left", "arm", ["j1"]) - ] + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("left", "arm", ["j1"])] + ) module._last_plan = GeneratedPlan( group_ids=("left/arm",), path=[ diff --git a/dimos/robot/config.py b/dimos/robot/config.py index 829b7d646d..e57859d9ea 100644 --- a/dimos/robot/config.py +++ b/dimos/robot/config.py @@ -28,7 +28,7 @@ from dimos.control.components import HardwareComponent, HardwareType from dimos.control.coordinator import TaskConfig -from dimos.manipulation.planning.planning_groups import discover_planning_group_definitions +from dimos.manipulation.planning.groups import discover_planning_group_definitions from dimos.manipulation.planning.planning_identifiers import make_global_joint_names from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped From 2a7524a35db18a222442298bd59b0aff4ee440bc Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 20:33:17 -0700 Subject: [PATCH 051/110] feat: add openarm dual whole-body adapter --- CONTEXT.md | 21 + dimos/control/hardware_interface.py | 50 +- dimos/control/test_hardware_interface.py | 107 ++++ dimos/hardware/damiao/__init__.py | 38 ++ dimos/hardware/damiao/arm_adapter.py | 408 ++++++++++++ dimos/hardware/damiao/runtime.py | 379 ++++++++++++ .../{manipulators => }/damiao/specs.py | 184 +++++- dimos/hardware/damiao/test_adapters.py | 309 +++++++++ dimos/hardware/damiao/whole_body_adapter.py | 251 ++++++++ .../manipulators/damiao/base_adapter.py | 584 ------------------ .../manipulators/damiao/test_base_adapter.py | 188 ------ .../manipulators/openarm_rs/adapter.py | 16 +- dimos/hardware/whole_body/openarm/__init__.py | 15 + dimos/hardware/whole_body/openarm/adapter.py | 169 +++++ .../whole_body/openarm/test_adapter.py | 135 ++++ dimos/robot/all_blueprints.py | 1 + .../robot/manipulators/openarm/blueprints.py | 34 +- 17 files changed, 2075 insertions(+), 814 deletions(-) create mode 100644 CONTEXT.md create mode 100644 dimos/control/test_hardware_interface.py create mode 100644 dimos/hardware/damiao/__init__.py create mode 100644 dimos/hardware/damiao/arm_adapter.py create mode 100644 dimos/hardware/damiao/runtime.py rename dimos/hardware/{manipulators => }/damiao/specs.py (50%) create mode 100644 dimos/hardware/damiao/test_adapters.py create mode 100644 dimos/hardware/damiao/whole_body_adapter.py delete mode 100644 dimos/hardware/manipulators/damiao/base_adapter.py delete mode 100644 dimos/hardware/manipulators/damiao/test_base_adapter.py create mode 100644 dimos/hardware/whole_body/openarm/__init__.py create mode 100644 dimos/hardware/whole_body/openarm/adapter.py create mode 100644 dimos/hardware/whole_body/openarm/test_adapter.py diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..80f0d72a46 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,21 @@ +# DimOS Robotics Context + +DimOS describes robots, actuators, and control surfaces using precise robotics terminology. This glossary records domain language only, not implementation details. + +## Language + +**Damiao-based Robot**: +A robot whose joints are actuated by one or more Damiao motors, possibly spread across multiple CAN buses and physical limbs. +_Avoid_: Damiao arm when the robot may contain multiple motor groups + +**Damiao Joint Group**: +An ordered set of Damiao-driven joints that forms a meaningful physical group such as an arm, torso, or other controllable body section. +_Avoid_: Arm when the group is not necessarily an arm + +**Damiao Bus**: +A named communication channel used by a Damiao-based Robot to reach one or more Damiao motors. +_Avoid_: Treating a bus as owned by a single joint group when multiple groups may share a channel + +**OpenArm**: +An OpenArm robot configuration built from Damiao motors, with OpenArm-specific joints, side naming, limits, and robot description. +_Avoid_: Damiao robot when referring to OpenArm-specific geometry or naming diff --git a/dimos/control/hardware_interface.py b/dimos/control/hardware_interface.py index 5e5bb2a3da..05cecda3c7 100644 --- a/dimos/control/hardware_interface.py +++ b/dimos/control/hardware_interface.py @@ -281,16 +281,17 @@ def read_state(self) -> dict[JointName, JointState]: for i, name in enumerate(self._joint_names) } - def write_command(self, commands: dict[str, float], _mode: ControlMode) -> bool: + def write_command(self, commands: dict[str, float], mode: ControlMode) -> bool: """Write velocity commands — always sends velocities regardless of mode. Args: commands: {joint_name: velocity} - can be partial - _mode: Control mode (ignored — twist bases always use velocity) + mode: Control mode (ignored — twist bases always use velocity) Returns: True if command was sent successfully """ + del mode # Update last commanded for joints we received for joint_name, value in commands.items(): if joint_name in self._last_commanded: @@ -390,15 +391,7 @@ def read_state(self) -> dict[JointName, JointState]: } def write_command(self, commands: dict[str, float], mode: ControlMode) -> bool: - """Write position commands — converts to MotorCommand with per-joint PD gains. - - Only POSITION / SERVO_POSITION are supported; other modes are warned - and dropped (matches ConnectedHardware's warn-and-skip pattern). - Per-joint kp/kd come from ``component.wb_config`` (resolved in - ``__init__``); fall back to ``_DEFAULT_KP``/``_DEFAULT_KD`` when - the blueprint didn't supply gains. - """ - from dimos.hardware.whole_body.spec import MotorCommand + """Dispatch coordinator joint-position commands by control mode.""" if mode not in (ControlMode.POSITION, ControlMode.SERVO_POSITION): logger.warning( @@ -406,13 +399,23 @@ def write_command(self, commands: dict[str, float], mode: ControlMode) -> bool: f"got {mode.name} — skipping" ) return False + return self.write_position(commands) + + def write_position(self, commands: dict[str, float]) -> bool: + """Write named position commands using native adapter position IO when available. + + Unknown joints are warned once and ignored. Partial commands hold the + previous commanded value for omitted joints. The hold-last cache is + committed only after the underlying adapter accepts the full frame. + """ if not self._initialized and not self._try_initialize_last_commanded(): return False + candidate = dict(self._last_commanded) for joint_name, value in commands.items(): if joint_name in self._joint_names: - self._last_commanded[joint_name] = value + candidate[joint_name] = value elif joint_name not in self._warned_unknown_joints: logger.warning( f"WholeBody {self.hardware_id} received command for unknown joint " @@ -420,9 +423,19 @@ def write_command(self, commands: dict[str, float], mode: ControlMode) -> bool: ) self._warned_unknown_joints.add(joint_name) + positions = [candidate[name] for name in self._joint_names] + write_joint_positions = getattr(self._wb_adapter, "write_joint_positions", None) + if callable(write_joint_positions): + ok = bool(write_joint_positions(positions)) + if ok: + self._last_commanded = candidate + return ok + + from dimos.hardware.whole_body.spec import MotorCommand + motor_cmds = [ MotorCommand( - q=self._last_commanded[name], + q=candidate[name], dq=0.0, kp=self._kp_by_name[name], kd=self._kd_by_name[name], @@ -430,7 +443,10 @@ def write_command(self, commands: dict[str, float], mode: ControlMode) -> bool: ) for name in self._joint_names ] - return self._wb_adapter.write_motor_commands(motor_cmds) + ok = self._wb_adapter.write_motor_commands(motor_cmds) + if ok: + self._last_commanded = candidate + return ok def write_motor_commands(self, commands: list[MotorCommand]) -> bool: """Direct pass-through to adapter for full MotorCommand control.""" @@ -441,6 +457,12 @@ def _try_initialize_last_commanded(self) -> bool: if not self._wb_adapter.has_motor_states(): return False states = self._wb_adapter.read_motor_states() + if len(states) != len(self._joint_names): + logger.warning( + f"WholeBody {self.hardware_id} read {len(states)} motor states for " + f"{len(self._joint_names)} joints; skipping command initialization" + ) + return False for i, name in enumerate(self._joint_names): self._last_commanded[name] = states[i].q self._initialized = True diff --git a/dimos/control/test_hardware_interface.py b/dimos/control/test_hardware_interface.py new file mode 100644 index 0000000000..13767b17ad --- /dev/null +++ b/dimos/control/test_hardware_interface.py @@ -0,0 +1,107 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dimos.control.components import HardwareComponent, HardwareType +from dimos.control.hardware_interface import ConnectedWholeBody +from dimos.hardware.whole_body.spec import IMUState, MotorCommand, MotorState + + +class _NativePositionWholeBodyAdapter: + def __init__(self) -> None: + self.accept = False + self.position_writes: list[list[float]] = [] + + def connect(self) -> bool: + return True + + def disconnect(self) -> None: + return None + + def is_connected(self) -> bool: + return True + + def read_motor_states(self) -> list[MotorState]: + return [MotorState(q=0.0), MotorState(q=0.0)] + + def has_motor_states(self) -> bool: + return True + + def read_imu(self) -> IMUState: + return IMUState() + + def write_motor_commands(self, commands: list[MotorCommand]) -> bool: + raise AssertionError("native position path should not call write_motor_commands") + + def write_joint_positions(self, positions: list[float]) -> bool: + self.position_writes.append(list(positions)) + return self.accept + + +class _MotorCommandWholeBodyAdapter: + def __init__(self) -> None: + self.motor_writes: list[list[MotorCommand]] = [] + + def connect(self) -> bool: + return True + + def disconnect(self) -> None: + return None + + def is_connected(self) -> bool: + return True + + def read_motor_states(self) -> list[MotorState]: + return [MotorState(q=0.0), MotorState(q=0.0)] + + def has_motor_states(self) -> bool: + return True + + def read_imu(self) -> IMUState: + return IMUState() + + def write_motor_commands(self, commands: list[MotorCommand]) -> bool: + self.motor_writes.append(commands) + return True + + +def _component() -> HardwareComponent: + return HardwareComponent( + hardware_id="body", + hardware_type=HardwareType.WHOLE_BODY, + joints=["body/j1", "body/j2"], + ) + + +def test_whole_body_write_position_uses_native_adapter_and_commits_only_on_success() -> None: + adapter = _NativePositionWholeBodyAdapter() + connected = ConnectedWholeBody(adapter=adapter, component=_component()) + + assert connected.write_position({"body/j1": 2.0}) is False + adapter.accept = True + assert connected.write_position({"body/j2": 3.0}) is True + + assert adapter.position_writes == [[2.0, 0.0], [0.0, 3.0]] + + +def test_whole_body_write_position_falls_back_to_motor_commands() -> None: + adapter = _MotorCommandWholeBodyAdapter() + connected = ConnectedWholeBody(adapter=adapter, component=_component()) + + assert connected.write_position({"body/j1": 1.0}) is True + + commands = adapter.motor_writes[-1] + assert [command.q for command in commands] == [1.0, 0.0] + assert [command.dq for command in commands] == [0.0, 0.0] diff --git a/dimos/hardware/damiao/__init__.py b/dimos/hardware/damiao/__init__.py new file mode 100644 index 0000000000..dc923c1c3e --- /dev/null +++ b/dimos/hardware/damiao/__init__.py @@ -0,0 +1,38 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared Damiao actuator/runtime adapters.""" + +from dimos.hardware.damiao.arm_adapter import DamiaoArmAdapter +from dimos.hardware.damiao.runtime import DamiaoBindingUnavailableError, DamiaoRobotRuntime +from dimos.hardware.damiao.specs import ( + DamiaoArmSpec, + DamiaoBusSpec, + DamiaoJointGroupSpec, + DamiaoMotorSpec, + DamiaoRobotSpec, +) +from dimos.hardware.damiao.whole_body_adapter import DamiaoWholeBodyAdapter + +__all__ = [ + "DamiaoArmAdapter", + "DamiaoArmSpec", + "DamiaoBindingUnavailableError", + "DamiaoBusSpec", + "DamiaoJointGroupSpec", + "DamiaoMotorSpec", + "DamiaoRobotRuntime", + "DamiaoRobotSpec", + "DamiaoWholeBodyAdapter", +] diff --git a/dimos/hardware/damiao/arm_adapter.py b/dimos/hardware/damiao/arm_adapter.py new file mode 100644 index 0000000000..23de06bc71 --- /dev/null +++ b/dimos/hardware/damiao/arm_adapter.py @@ -0,0 +1,408 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np + +from dimos.hardware.damiao.runtime import ( + _DEFAULT_ADDRESS, + _DEFAULT_STATE_CACHE_TTL_S, + _DEFAULT_TICK_DEADLINE_US, + DamiaoBindingUnavailableError, + DamiaoRobotRuntime, +) +from dimos.hardware.damiao.specs import DamiaoArmSpec, DamiaoRobotSpec +from dimos.hardware.manipulators.spec import ControlMode, JointLimits, ManipulatorInfo +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +_CONTROL_MODE_INDEX = {mode: index for index, mode in enumerate(ControlMode)} + + +def _dynamic_attr(value: object, name: str) -> Any: + return getattr(value, name) + + +class DamiaoArmAdapter: + """ManipulatorAdapter facade over one Damiao joint group.""" + + _adapter_type: str = "damiao" + _binding_error_type: type[RuntimeError] = DamiaoBindingUnavailableError + _supported_control_modes: tuple[ControlMode, ...] = ( + ControlMode.POSITION, + ControlMode.SERVO_POSITION, + ControlMode.TORQUE, + ) + + def __init__( + self, + *, + robot_spec: DamiaoRobotSpec, + group_name: str, + dof: int | None = None, + hardware_id: str = "arm", + kp: list[float] | None = None, + kd: list[float] | None = None, + gravity_comp: bool = True, + gravity_model_path: str | Path | None = None, + gravity_torque_limits: list[float] | tuple[float, ...] | None = None, + supported_control_modes: tuple[ControlMode, ...] | None = None, + use_mock_bus: bool = False, + config_path: str | Path | None = None, + tick_deadline_us: int = _DEFAULT_TICK_DEADLINE_US, + state_cache_ttl_s: float = _DEFAULT_STATE_CACHE_TTL_S, + ) -> None: + robot_spec.validate() + if group_name not in robot_spec.groups: + raise ValueError(f"unknown Damiao group {group_name!r}") + group_spec = robot_spec.groups[group_name] + if dof is not None and dof != group_spec.dof: + raise ValueError( + f"{type(self).__name__} only supports {group_spec.dof} DOF (got {dof})" + ) + self._robot_spec = robot_spec + self._group_name = group_name + self._group_spec = group_spec + self._hardware_id = hardware_id + self._dof = group_spec.dof + self._position_lower = list(group_spec.position_lower) + self._position_upper = list(group_spec.position_upper) + self._velocity_max = list(group_spec.velocity_max) + self._kp = list(kp) if kp is not None else list(group_spec.kp) + self._kd = list(kd) if kd is not None else list(group_spec.kd) + self._validate_length("kp", self._kp) + self._validate_length("kd", self._kd) + self._gravity_comp = gravity_comp + resolved_gravity_model = ( + gravity_model_path if gravity_model_path is not None else group_spec.gravity_model_path + ) + self._gravity_model_path = str(resolved_gravity_model) if resolved_gravity_model else None + resolved_torque_limits = ( + gravity_torque_limits + if gravity_torque_limits is not None + else group_spec.gravity_torque_limits + ) + self._gravity_torque_limits = ( + list(resolved_torque_limits) if resolved_torque_limits else None + ) + if self._gravity_torque_limits is not None: + self._validate_length("gravity_torque_limits", self._gravity_torque_limits) + self._supported_control_modes = ( + supported_control_modes or type(self)._supported_control_modes + ) + self._control_mode = ControlMode.POSITION + self._last_positions: list[float] | None = None + self._pin_model: object | None = None + self._pin_data: object | None = None + self._use_mock_bus = use_mock_bus + self._config_path = config_path + self._tick_deadline_us = tick_deadline_us + self._state_cache_ttl_s = state_cache_ttl_s + self._runtime: DamiaoRobotRuntime | None = None + self._connected = False + self._enabled = False + + @classmethod + def from_arm_spec( + cls, + *, + arm_spec: DamiaoArmSpec, + address: str | Path | None = _DEFAULT_ADDRESS, + **kwargs: Any, + ) -> DamiaoArmAdapter: + """Build a one-group adapter from a compatibility arm spec.""" + + robot_spec = DamiaoRobotSpec.from_arm_spec( + arm_spec, + address=str(address) if address is not None else _DEFAULT_ADDRESS, + ) + return cls(robot_spec=robot_spec, group_name=arm_spec.arm_name, **kwargs) + + def _create_runtime(self) -> DamiaoRobotRuntime: + return DamiaoRobotRuntime( + robot_spec=self._robot_spec, + adapter_type=self._adapter_type, + binding_error_type=self._binding_error_type, + use_mock_bus=self._use_mock_bus, + config_path=self._config_path, + tick_deadline_us=self._tick_deadline_us, + state_cache_ttl_s=self._state_cache_ttl_s, + ) + + def _validate_length(self, name: str, values: list[float]) -> None: + if len(values) != self._dof: + raise ValueError(f"{name} length {len(values)} does not match dof {self._dof}") + + def _validate_command_lengths(self, **commands: list[float]) -> None: + for name, values in commands.items(): + self._validate_length(name, values) + + def _zero_vector(self) -> list[float]: + return [0.0] * self._dof + + def connect(self) -> bool: + try: + runtime = self._create_runtime() + if not runtime.connect(): + return False + self._runtime = runtime + self._load_gravity_model() + self._connected = True + self.refresh_state(force=True) + except self._binding_error_type: + raise + except Exception: + logger.exception( + "damiao arm adapter connect failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + ) + self.disconnect() + return False + return True + + def disconnect(self) -> None: + if self._runtime is not None: + self._runtime.disconnect() + self._runtime = None + self._connected = False + self._enabled = False + + def is_connected(self) -> bool: + return self._connected + + def activate(self) -> bool: + return self.write_enable(True) + + def deactivate(self) -> bool: + stopped = self.write_stop() + disabled = self.write_enable(False) + return stopped and disabled + + def get_info(self) -> ManipulatorInfo: + return ManipulatorInfo( + vendor=self._robot_spec.vendor, + model=self._robot_spec.model, + dof=self._dof, + firmware_version=None, + serial_number=None, + ) + + def get_dof(self) -> int: + return self._dof + + def get_limits(self) -> JointLimits: + return JointLimits( + position_lower=list(self._position_lower), + position_upper=list(self._position_upper), + velocity_max=list(self._velocity_max), + ) + + def set_control_mode(self, mode: ControlMode) -> bool: + if mode not in self._supported_control_modes: + return False + self._control_mode = mode + return True + + def get_control_mode(self) -> ControlMode: + return self._control_mode + + def read_enabled(self) -> bool: + return self._enabled + + def refresh_state(self, *, force: bool = False) -> tuple[list[float], list[float], list[float]]: + if self._runtime is None: + raise RuntimeError(f"{type(self).__name__} is not connected") + state = self._runtime.refresh_group_state(self._group_name, force=force) + self._last_positions = list(state.q) + return list(state.q), list(state.dq), list(state.tau) + + def read_joint_positions(self) -> list[float]: + return list(self.refresh_state()[0]) + + def read_joint_velocities(self) -> list[float]: + return list(self.refresh_state()[1]) + + def read_joint_efforts(self) -> list[float]: + return list(self.refresh_state()[2]) + + def read_state(self) -> dict[str, int]: + return {"state": 1 if self._enabled else 0, "mode": _CONTROL_MODE_INDEX[self._control_mode]} + + def read_error(self) -> tuple[int, str]: + return 0, "" + + def read_cartesian_position(self) -> dict[str, float] | None: + return None + + def write_cartesian_position(self, pose: dict[str, float], velocity: float = 1.0) -> bool: + return False + + def read_gripper_position(self) -> float | None: + return None + + def write_gripper_position(self, position: float) -> bool: + return False + + def read_force_torque(self) -> list[float] | None: + return None + + def write_joint_positions(self, positions: list[float], velocity: float = 1.0) -> bool: + if self._runtime is None or not self._enabled or len(positions) != self._dof: + return False + velocity = max(0.0, min(1.0, velocity)) + if self._gravity_comp: + try: + tau = self.compute_gravity_torques(self.read_joint_positions()) + except RuntimeError: + logger.warning( + "damiao arm adapter dropping gravity feed-forward; state read failed", + adapter=type(self).__name__, + hardware_id=self._hardware_id, + exc_info=True, + ) + tau = self._zero_vector() + else: + tau = self._zero_vector() + return self.write_mit_commands( + q=list(positions), + dq=self._zero_vector(), + kp=[kp * velocity for kp in self._kp], + kd=list(self._kd), + tau=tau, + ) + + def write_joint_velocities(self, velocities: list[float]) -> bool: + return False + + def write_joint_torques(self, efforts: list[float]) -> bool: + if self._runtime is None or not self._enabled or len(efforts) != self._dof: + return False + q = ( + self._last_positions + if self._last_positions is not None + else self.read_joint_positions() + ) + return self.write_mit_commands( + q=q, + dq=self._zero_vector(), + kp=self._zero_vector(), + kd=self._zero_vector(), + tau=efforts, + ) + + def write_mit_commands( + self, + *, + q: list[float], + dq: list[float], + kp: list[float], + kd: list[float], + tau: list[float], + ) -> bool: + if self._runtime is None or not self._enabled: + return False + self._validate_command_lengths(q=q, dq=dq, kp=kp, kd=kd, tau=tau) + ok = self._runtime.write_group_mit_commands( + group_name=self._group_name, + q=q, + dq=dq, + kp=kp, + kd=kd, + tau=tau, + ) + if ok: + self._last_positions = list(q) + self._control_mode = ( + ControlMode.TORQUE if all(k == 0.0 for k in kp) else ControlMode.POSITION + ) + return ok + + def write_stop(self) -> bool: + if self._runtime is None: + return False + if self._gravity_comp and self._enabled: + try: + q_now = self.read_joint_positions() + except RuntimeError: + self._runtime.disable() + self._enabled = False + return False + return self.write_mit_commands( + q=q_now, + dq=self._zero_vector(), + kp=list(self._kp), + kd=list(self._kd), + tau=self.compute_gravity_torques(q_now), + ) + disabled = self._runtime.disable() + if disabled: + self._enabled = False + return disabled + + def write_enable(self, enable: bool) -> bool: + if self._runtime is None: + return False + ok = self._runtime.enable() if enable else self._runtime.disable() + if not ok: + return False + self._enabled = enable + if enable: + positions = self.read_joint_positions() + if not self.write_joint_positions(positions): + self._runtime.disable() + self._enabled = False + return False + return True + + def write_clear_errors(self) -> bool: + if self._runtime is None: + return False + if not self._runtime.disable() or not self._runtime.enable(): + return False + self._enabled = True + return self.write_joint_positions(self.read_joint_positions()) + + def _load_gravity_model(self) -> None: + if not self._gravity_comp or self._gravity_model_path is None or self._runtime is None: + return + loaded = self._runtime.load_gravity_model(self._group_name, self._gravity_model_path) + if loaded is not None: + self._pin_model, self._pin_data = loaded + + def compute_gravity_torques(self, q: list[float]) -> list[float]: + self._validate_length("q", q) + if self._pin_model is None or self._pin_data is None: + return self._zero_vector() + import pinocchio # type: ignore[import-not-found] + + compute_generalized_gravity = _dynamic_attr(pinocchio, "computeGeneralizedGravity") + tau = compute_generalized_gravity( + self._pin_model, self._pin_data, np.array(q, dtype=np.float64) + ) + values = [float(tau[i]) for i in range(self._dof)] + if self._gravity_torque_limits is None: + return values + return [ + float(np.clip(value, -limit, limit)) + for value, limit in zip(values, self._gravity_torque_limits, strict=False) + ] + + +__all__ = ["DamiaoArmAdapter"] diff --git a/dimos/hardware/damiao/runtime.py b/dimos/hardware/damiao/runtime.py new file mode 100644 index 0000000000..cf431b005d --- /dev/null +++ b/dimos/hardware/damiao/runtime.py @@ -0,0 +1,379 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +import importlib +from pathlib import Path +import time +from typing import Any, cast + +import numpy as np + +from dimos.hardware.damiao.specs import DamiaoJointGroupSpec, DamiaoRobotSpec +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +_DEFAULT_TICK_DEADLINE_US = 1_000 +_DEFAULT_STATE_CACHE_TTL_S = 0.002 +_DEFAULT_ADDRESS = "can0" + + +class DamiaoBindingUnavailableError(RuntimeError): + """Raised when the optional can_motor_control binding is unavailable.""" + + +@dataclass(frozen=True) +class DamiaoGroupState: + """State vectors for one Damiao joint group.""" + + q: list[float] + dq: list[float] + tau: list[float] + + +def _load_can_motor_control( + *, + adapter_type: str, + error_type: type[RuntimeError] = DamiaoBindingUnavailableError, +) -> tuple[Any, Any]: + """Lazily load the optional Rust-backed binding and Damiao codec module.""" + + try: + can_motor_control = importlib.import_module("can_motor_control") + damiao = importlib.import_module("can_motor_control.damiao") + except ImportError as exc: + raise error_type( + f"The selected '{adapter_type}' adapter requires the Rust-backed " + "can-motor-control Python binding in the active environment. Install " + f"dimos[manipulation] before selecting adapter_type='{adapter_type}'." + ) from exc + return can_motor_control, damiao + + +def _dynamic_attr(value: object, name: str) -> Any: + return getattr(value, name) + + +class DamiaoRobotRuntime: + """Binding-backed runtime for one Damiao-based robot spec.""" + + def __init__( + self, + *, + robot_spec: DamiaoRobotSpec, + adapter_type: str = "damiao", + binding_error_type: type[RuntimeError] = DamiaoBindingUnavailableError, + use_mock_bus: bool = False, + config_path: str | Path | None = None, + tick_deadline_us: int = _DEFAULT_TICK_DEADLINE_US, + state_cache_ttl_s: float = _DEFAULT_STATE_CACHE_TTL_S, + ) -> None: + robot_spec.validate() + self._robot_spec = robot_spec + self._adapter_type = adapter_type + self._binding_error_type = binding_error_type + self._use_mock_bus = use_mock_bus + self._config_path = str(config_path) if config_path is not None else None + self._tick_deadline_us = tick_deadline_us + self._state_cache_ttl_s = state_cache_ttl_s + self._robot: Any | None = None + self._groups: dict[str, Any] = {} + self._state_cache: dict[str, DamiaoGroupState] = {} + self._state_cache_time: dict[str, float] = {} + self._can_motor_control: Any | None = None + self._damiao: Any | None = None + self._connected = False + self._enabled = False + + @property + def robot_spec(self) -> DamiaoRobotSpec: + return self._robot_spec + + def connect(self) -> bool: + """Connect the binding robot and cache group handles.""" + + try: + self._can_motor_control, self._damiao = _load_can_motor_control( + adapter_type=self._adapter_type, + error_type=self._binding_error_type, + ) + robot = self._build_robot() + robot.connect() + groups: dict[str, Any] = {} + for group_name, group_spec in self._robot_spec.groups.items(): + group = robot[group_name] + if len(group) != group_spec.dof: + raise RuntimeError( + f"can_motor_control group {group_name!r} has {len(group)} joints, " + f"expected {group_spec.dof}" + ) + groups[group_name] = group + self._robot = robot + self._groups = groups + self._connected = True + for group_name in self._robot_spec.groups: + self.refresh_group_state(group_name, force=True) + except self._binding_error_type: + raise + except Exception: + logger.exception("damiao runtime connect failed", adapter=self._adapter_type) + self.disconnect() + return False + return True + + def _build_robot(self) -> Any: + if self._can_motor_control is None or self._damiao is None: + raise RuntimeError("can_motor_control binding is not loaded") + if self._config_path is not None: + return self._can_motor_control.Robot.from_config(self._config_path) + builder = self._can_motor_control.Robot.builder() + codec = self._damiao.DamiaoCodec() + for bus_name, bus_spec in self._robot_spec.buses.items(): + address = str(bus_spec.address or _DEFAULT_ADDRESS) + transport = ( + self._can_motor_control.MockCanBus.new_fd(address) + if self._use_mock_bus and bus_spec.fd + else self._can_motor_control.MockCanBus(address) + if self._use_mock_bus + else self._can_motor_control.SocketCanBus(address, fd=bus_spec.fd) + ) + builder = builder.add_bus(bus_name, transport, codec) + for group_name, group_spec in self._robot_spec.groups.items(): + binding_specs = [ + self._can_motor_control.MotorSpec( + motor.name, + cast("int", self._resolve_motor_type(motor.type)), + motor.send_id, + motor.effective_recv_id, + ) + for motor in group_spec.motors + ] + builder = builder.add_arm(group_name, bus=group_spec.bus_name, motors=binding_specs) + return builder.build() + + def _resolve_motor_type(self, motor_type: object) -> object: + if self._damiao is None: + raise RuntimeError("Damiao binding module is not loaded") + if isinstance(motor_type, str): + try: + return getattr(self._damiao.MotorType, motor_type) + except AttributeError as exc: + raise ValueError(f"Unknown Damiao motor type {motor_type!r}") from exc + if not isinstance(motor_type, int): + return motor_type + for name in dir(self._damiao.MotorType): + if name.startswith("_"): + continue + candidate = getattr(self._damiao.MotorType, name) + try: + candidate_value = int(candidate) + except (TypeError, ValueError): + continue + if candidate_value == motor_type: + return candidate + raise ValueError(f"Unknown Damiao motor type value {motor_type!r}") + + def disconnect(self) -> None: + """Disable and drop the underlying binding robot.""" + + if self._robot is not None: + try: + self._robot.disable() + except Exception: + logger.warning("damiao runtime disable on disconnect failed", exc_info=True) + self._enabled = False + self._connected = False + self._robot = None + self._groups = {} + self._state_cache = {} + self._state_cache_time = {} + + def is_connected(self) -> bool: + return self._connected + + def enable(self) -> bool: + if self._robot is None: + return False + try: + self._robot.enable() + except Exception: + logger.exception("damiao runtime enable failed", adapter=self._adapter_type) + return False + self._enabled = True + return True + + def disable(self) -> bool: + if self._robot is None: + return False + try: + self._robot.disable() + except Exception: + logger.exception("damiao runtime disable failed", adapter=self._adapter_type) + return False + self._enabled = False + return True + + def is_enabled(self) -> bool: + return self._enabled + + def group_spec(self, group_name: str) -> DamiaoJointGroupSpec: + try: + return self._robot_spec.groups[group_name] + except KeyError as exc: + raise ValueError(f"unknown Damiao group {group_name!r}") from exc + + def refresh_group_state(self, group_name: str, *, force: bool = False) -> DamiaoGroupState: + group_spec = self.group_spec(group_name) + group = self._groups.get(group_name) + if self._robot is None or group is None: + raise RuntimeError("DamiaoRobotRuntime is not connected") + now = time.monotonic() + cached = self._state_cache.get(group_name) + cached_at = self._state_cache_time.get(group_name, 0.0) + if not force and cached is not None and now - cached_at <= self._state_cache_ttl_s: + return cached + group.refresh() + self._robot.tick(self._tick_deadline_us) + state = DamiaoGroupState( + q=group.positions().astype(np.float64).tolist(), + dq=group.velocities().astype(np.float64).tolist(), + tau=group.torques().astype(np.float64).tolist(), + ) + if any(len(values) != group_spec.dof for values in (state.q, state.dq, state.tau)): + raise RuntimeError( + f"state length does not match configured DOF for group {group_name!r}" + ) + self._state_cache[group_name] = state + self._state_cache_time[group_name] = time.monotonic() + return state + + def has_group_states(self, group_names: Sequence[str]) -> bool: + """Return true only when every requested group has a fresh complete state.""" + + try: + for group_name in group_names: + self.refresh_group_state(group_name, force=False) + except Exception: + return False + return True + + def read_group_states(self, group_names: Sequence[str]) -> list[DamiaoGroupState]: + """Read state for groups in the requested order.""" + + return [self.refresh_group_state(group_name, force=False) for group_name in group_names] + + def write_group_mit_commands( + self, + *, + group_name: str, + q: Sequence[float], + dq: Sequence[float], + kp: Sequence[float], + kd: Sequence[float], + tau: Sequence[float], + ) -> bool: + """Write one MIT command frame to a group.""" + + group_spec = self.group_spec(group_name) + group = self._groups.get(group_name) + if self._robot is None or group is None or not self._enabled: + return False + if any(len(values) != group_spec.dof for values in (q, dq, kp, kd, tau)): + raise ValueError( + f"command length does not match configured DOF for group {group_name!r}" + ) + try: + group.mit_control(np.column_stack([kp, kd, q, dq, tau]).astype(np.float64)) + self._robot.tick(self._tick_deadline_us) + except Exception: + logger.exception("damiao runtime MIT command failed", group_name=group_name) + return False + self._state_cache.pop(group_name, None) + self._state_cache_time.pop(group_name, None) + return True + + def write_groups_mit_commands( + self, + commands: Mapping[ + str, + tuple[ + Sequence[float], Sequence[float], Sequence[float], Sequence[float], Sequence[float] + ], + ], + ) -> bool: + """Stage MIT commands for multiple groups and tick once. + + The binding's group ``mit_control`` call stages commands; ``robot.tick`` + sends them. Validate all groups and command lengths before staging so a + bad frame is rejected without sending a partial whole-body command. + """ + + if self._robot is None or not self._enabled: + return False + for group_name, values in commands.items(): + group_spec = self.group_spec(group_name) + group = self._groups.get(group_name) + if group is None: + return False + q, dq, kp, kd, tau = values + if any(len(vector) != group_spec.dof for vector in (q, dq, kp, kd, tau)): + raise ValueError( + f"command length does not match configured DOF for group {group_name!r}" + ) + try: + for group_name, values in commands.items(): + q, dq, kp, kd, tau = values + self._groups[group_name].mit_control( + np.column_stack([kp, kd, q, dq, tau]).astype(np.float64) + ) + self._robot.tick(self._tick_deadline_us) + except Exception: + logger.exception("damiao runtime batched MIT command failed") + return False + for group_name in commands: + self._state_cache.pop(group_name, None) + self._state_cache_time.pop(group_name, None) + return True + + def load_gravity_model( + self, + group_name: str, + model_path: str | Path | None = None, + ) -> tuple[object, object] | None: + """Load a Pinocchio gravity model for a configured group, if present.""" + + resolved_model_path = ( + model_path if model_path is not None else self.group_spec(group_name).gravity_model_path + ) + if resolved_model_path is None: + return None + import pinocchio # type: ignore[import-not-found] + + build_model_from_urdf = _dynamic_attr(pinocchio, "buildModelFromUrdf") + model = build_model_from_urdf(str(resolved_model_path)) + return model, _dynamic_attr(model, "createData")() + + +__all__ = [ + "_DEFAULT_ADDRESS", + "_DEFAULT_STATE_CACHE_TTL_S", + "_DEFAULT_TICK_DEADLINE_US", + "DamiaoBindingUnavailableError", + "DamiaoGroupState", + "DamiaoRobotRuntime", +] diff --git a/dimos/hardware/manipulators/damiao/specs.py b/dimos/hardware/damiao/specs.py similarity index 50% rename from dimos/hardware/manipulators/damiao/specs.py rename to dimos/hardware/damiao/specs.py index af3363a367..f67d6a0ecf 100644 --- a/dimos/hardware/manipulators/damiao/specs.py +++ b/dimos/hardware/damiao/specs.py @@ -35,9 +35,162 @@ def effective_recv_id(self) -> int: return self.recv_id if self.recv_id is not None else (self.send_id | 0x10) +@dataclass(frozen=True) +class DamiaoBusSpec: + """Named communication channel for Damiao motors.""" + + address: str | Path = "can0" + fd: bool = False + + +@dataclass(frozen=True) +class DamiaoJointGroupSpec: + """Ordered Damiao joints forming a controllable physical group.""" + + bus_name: str + motors: tuple[DamiaoMotorSpec, ...] + position_lower: tuple[float, ...] + position_upper: tuple[float, ...] + velocity_max: tuple[float, ...] + kp: tuple[float, ...] + kd: tuple[float, ...] + gravity_model_path: str | Path | None = None + gravity_torque_limits: tuple[float, ...] | None = None + supports_velocity: bool = False + + @property + def dof(self) -> int: + """Return the number of joints described by this group spec.""" + + return len(self.motors) + + @property + def joint_names(self) -> tuple[str, ...]: + """Return joint names in command-vector order.""" + + return tuple(motor.name for motor in self.motors) + + def validate(self, *, group_name: str, bus_names: set[str] | None = None) -> None: + """Validate per-group metadata and optional bus reference.""" + + if not self.motors: + raise ValueError(f"DamiaoJointGroupSpec {group_name!r} requires at least one motor") + if bus_names is not None and self.bus_name not in bus_names: + raise ValueError(f"group {group_name!r} references unknown bus {self.bus_name!r}") + send_ids = [motor.send_id for motor in self.motors] + if len(set(send_ids)) != len(send_ids): + raise ValueError(f"duplicate send_id in group {group_name!r}: {send_ids}") + recv_ids = [motor.effective_recv_id for motor in self.motors] + if len(set(recv_ids)) != len(recv_ids): + raise ValueError(f"duplicate recv_id in group {group_name!r}: {recv_ids}") + joint_names = [motor.name for motor in self.motors] + if len(set(joint_names)) != len(joint_names): + raise ValueError(f"duplicate joint name in group {group_name!r}: {joint_names}") + for name, values in { + "position_lower": self.position_lower, + "position_upper": self.position_upper, + "velocity_max": self.velocity_max, + "kp": self.kp, + "kd": self.kd, + }.items(): + if len(values) != self.dof: + raise ValueError( + f"{name} length {len(values)} does not match dof {self.dof} " + f"for group {group_name!r}" + ) + for index, (lower, upper) in enumerate( + zip(self.position_lower, self.position_upper, strict=True), + ): + if lower > upper: + raise ValueError( + f"position_lower[{index}] > position_upper[{index}] for group {group_name!r}" + ) + if self.gravity_torque_limits is not None and len(self.gravity_torque_limits) != self.dof: + raise ValueError( + f"gravity_torque_limits length does not match dof for group {group_name!r}" + ) + + +@dataclass(frozen=True) +class DamiaoRobotSpec: + """Python-native Damiao robot config with named buses and joint groups.""" + + name: str + vendor: str + model: str + buses: Mapping[str, DamiaoBusSpec] + groups: Mapping[str, DamiaoJointGroupSpec] + requires_binding: bool = False + + @property + def joint_names(self) -> tuple[str, ...]: + """Return all group joint names in mapping iteration order.""" + + return tuple(joint for group in self.groups.values() for joint in group.joint_names) + + def group_joint_names(self, group_names: Sequence[str]) -> tuple[str, ...]: + """Return concatenated joint names for the requested groups.""" + + return tuple( + joint for group_name in group_names for joint in self.groups[group_name].joint_names + ) + + def validate(self) -> None: + """Validate bus/group references and global joint-name uniqueness.""" + + if not self.buses: + raise ValueError("DamiaoRobotSpec requires at least one bus") + if not self.groups: + raise ValueError("DamiaoRobotSpec requires at least one joint group") + bus_names = set(self.buses) + all_joint_names: list[str] = [] + ids_by_bus: dict[str, set[int]] = {bus_name: set() for bus_name in bus_names} + for group_name, group in self.groups.items(): + group.validate(group_name=group_name, bus_names=bus_names) + all_joint_names.extend(group.joint_names) + bus_ids = ids_by_bus[group.bus_name] + for motor in group.motors: + if motor.send_id in bus_ids: + raise ValueError(f"duplicate send_id {motor.send_id} on bus {group.bus_name!r}") + bus_ids.add(motor.send_id) + if len(set(all_joint_names)) != len(all_joint_names): + raise ValueError(f"duplicate joint names across DamiaoRobotSpec: {all_joint_names}") + + @classmethod + def from_arm_spec( + cls, + arm_spec: DamiaoArmSpec, + *, + address: str | Path = "can0", + ) -> DamiaoRobotSpec: + """Build a one-group robot spec from a compatibility arm spec.""" + + return cls( + name=arm_spec.name, + vendor=arm_spec.vendor, + model=arm_spec.model, + buses={arm_spec.bus_name: DamiaoBusSpec(address=address, fd=arm_spec.fd)}, + groups={ + arm_spec.arm_name: DamiaoJointGroupSpec( + bus_name=arm_spec.bus_name, + motors=arm_spec.motors, + position_lower=arm_spec.position_lower, + position_upper=arm_spec.position_upper, + velocity_max=arm_spec.velocity_max, + kp=arm_spec.kp, + kd=arm_spec.kd, + gravity_model_path=arm_spec.gravity_model_path, + gravity_torque_limits=arm_spec.gravity_torque_limits, + supports_velocity=arm_spec.supports_velocity, + ) + }, + requires_binding=arm_spec.requires_binding, + ) + + @dataclass(frozen=True) class DamiaoArmSpec: - """Typed metadata for a Damiao-based arm adapter.""" + """Compatibility metadata for a single Damiao arm/group adapter.""" name: str vendor: str @@ -117,25 +270,7 @@ def from_values( def validate(self) -> None: """Validate CAN ID uniqueness and per-joint metadata lengths.""" - if not self.motors: - raise ValueError("DamiaoArmSpec requires at least one motor") - send_ids = [motor.send_id for motor in self.motors] - if len(set(send_ids)) != len(send_ids): - raise ValueError(f"duplicate send_id in {send_ids}") - recv_ids = [motor.effective_recv_id for motor in self.motors] - if len(set(recv_ids)) != len(recv_ids): - raise ValueError(f"duplicate recv_id in {recv_ids}") - for name, values in { - "position_lower": self.position_lower, - "position_upper": self.position_upper, - "velocity_max": self.velocity_max, - "kp": self.kp, - "kd": self.kd, - }.items(): - if len(values) != self.dof: - raise ValueError(f"{name} length {len(values)} does not match dof {self.dof}") - if self.gravity_torque_limits is not None and len(self.gravity_torque_limits) != self.dof: - raise ValueError("gravity_torque_limits length does not match dof") + DamiaoRobotSpec.from_arm_spec(self).validate() def coerce_motor_specs( @@ -171,4 +306,11 @@ def coerce_motor_specs( return tuple(specs) -__all__ = ["DamiaoArmSpec", "DamiaoMotorSpec", "coerce_motor_specs"] +__all__ = [ + "DamiaoArmSpec", + "DamiaoBusSpec", + "DamiaoJointGroupSpec", + "DamiaoMotorSpec", + "DamiaoRobotSpec", + "coerce_motor_specs", +] diff --git a/dimos/hardware/damiao/test_adapters.py b/dimos/hardware/damiao/test_adapters.py new file mode 100644 index 0000000000..243c82ce44 --- /dev/null +++ b/dimos/hardware/damiao/test_adapters.py @@ -0,0 +1,309 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import pytest + +from dimos.hardware.damiao.arm_adapter import DamiaoArmAdapter +from dimos.hardware.damiao.runtime import DamiaoGroupState +from dimos.hardware.damiao.specs import ( + DamiaoArmSpec, + DamiaoBusSpec, + DamiaoJointGroupSpec, + DamiaoMotorSpec, + DamiaoRobotSpec, +) +from dimos.hardware.damiao.whole_body_adapter import DamiaoWholeBodyAdapter +from dimos.hardware.manipulators.spec import ControlMode + + +class _FakeRuntime: + def __init__(self, *, fresh: bool = True, write_ok: bool = True) -> None: + self.fresh = fresh + self.write_ok = write_ok + self.connected = False + self.enabled = False + self.disconnect_calls = 0 + self.batched_calls = 0 + self.writes: list[ + tuple[str, list[float], list[float], list[float], list[float], list[float]] + ] = [] + self.loaded_gravity_models: list[tuple[str, str | None]] = [] + self.states = { + "left": DamiaoGroupState(q=[0.1], dq=[0.2], tau=[0.3]), + "right": DamiaoGroupState(q=[-0.1], dq=[-0.2], tau=[-0.3]), + "arm": DamiaoGroupState(q=[0.4, -0.4], dq=[0.5, -0.5], tau=[0.6, -0.6]), + } + + def connect(self) -> bool: + self.connected = True + return True + + def disconnect(self) -> None: + self.disconnect_calls += 1 + self.connected = False + self.enabled = False + + def enable(self) -> bool: + self.enabled = True + return True + + def disable(self) -> bool: + self.enabled = False + return True + + def is_enabled(self) -> bool: + return self.enabled + + def refresh_group_state(self, group_name: str, *, force: bool = False) -> DamiaoGroupState: + del force + return self.states[group_name] + + def has_group_states(self, group_names: tuple[str, ...]) -> bool: + return self.fresh and all(group_name in self.states for group_name in group_names) + + def read_group_states(self, group_names: tuple[str, ...]) -> list[DamiaoGroupState]: + if not self.has_group_states(group_names): + raise RuntimeError("stale state") + return [self.states[group_name] for group_name in group_names] + + def write_group_mit_commands( + self, + *, + group_name: str, + q: list[float], + dq: list[float], + kp: list[float], + kd: list[float], + tau: list[float], + ) -> bool: + if not self.write_ok: + return False + self.writes.append((group_name, list(q), list(dq), list(kp), list(kd), list(tau))) + return True + + def write_groups_mit_commands( + self, + commands: dict[str, tuple[list[float], list[float], list[float], list[float], list[float]]], + ) -> bool: + self.batched_calls += 1 + if not self.write_ok: + return False + for group_name, values in commands.items(): + q, dq, kp, kd, tau = values + self.writes.append((group_name, list(q), list(dq), list(kp), list(kd), list(tau))) + return True + + def load_gravity_model(self, group_name: str, model_path: str | None = None) -> None: + self.loaded_gravity_models.append((group_name, model_path)) + return None + + +def _arm_spec() -> DamiaoArmSpec: + return DamiaoArmSpec( + name="test_damiao", + vendor="Damiao", + model="TestArm", + motors=( + DamiaoMotorSpec("j1", "DM4310", 0x01, 0x11), + DamiaoMotorSpec("j2", "DM4310", 0x02, 0x12), + ), + position_lower=(-1.0, -2.0), + position_upper=(1.0, 2.0), + velocity_max=(3.0, 4.0), + kp=(5.0, 6.0), + kd=(0.1, 0.2), + gravity_torque_limits=(7.0, 8.0), + ) + + +def _whole_body_spec() -> DamiaoRobotSpec: + return DamiaoRobotSpec( + name="test_body", + vendor="Damiao", + model="TestBody", + buses={ + "left_can": DamiaoBusSpec(address="can1", fd=True), + "right_can": DamiaoBusSpec(address="can0", fd=True), + }, + groups={ + "left": DamiaoJointGroupSpec( + bus_name="left_can", + motors=(DamiaoMotorSpec("left_joint", "DM4310", 0x01, 0x11),), + position_lower=(-1.0,), + position_upper=(1.0,), + velocity_max=(3.0,), + kp=(5.0,), + kd=(0.1,), + ), + "right": DamiaoJointGroupSpec( + bus_name="right_can", + motors=(DamiaoMotorSpec("right_joint", "DM4310", 0x01, 0x11),), + position_lower=(-2.0,), + position_upper=(2.0,), + velocity_max=(4.0,), + kp=(6.0,), + kd=(0.2,), + ), + }, + ) + + +def test_robot_spec_rejects_unknown_group_bus() -> None: + spec = DamiaoRobotSpec( + name="bad", + vendor="Damiao", + model="Bad", + buses={"can": DamiaoBusSpec()}, + groups={ + "arm": DamiaoJointGroupSpec( + bus_name="missing", + motors=(DamiaoMotorSpec("j1", "DM4310", 0x01, 0x11),), + position_lower=(-1.0,), + position_upper=(1.0,), + velocity_max=(1.0,), + kp=(1.0,), + kd=(0.1,), + ) + }, + ) + + with pytest.raises(ValueError, match="unknown bus"): + spec.validate() + + +def test_robot_spec_rejects_duplicate_send_ids_on_shared_bus() -> None: + spec = DamiaoRobotSpec( + name="bad_ids", + vendor="Damiao", + model="BadIds", + buses={"can": DamiaoBusSpec()}, + groups={ + "left": DamiaoJointGroupSpec( + bus_name="can", + motors=(DamiaoMotorSpec("left_joint", "DM4310", 0x01, 0x11),), + position_lower=(-1.0,), + position_upper=(1.0,), + velocity_max=(1.0,), + kp=(1.0,), + kd=(0.1,), + ), + "right": DamiaoJointGroupSpec( + bus_name="can", + motors=(DamiaoMotorSpec("right_joint", "DM4310", 0x01, 0x12),), + position_lower=(-1.0,), + position_upper=(1.0,), + velocity_max=(1.0,), + kp=(1.0,), + kd=(0.1,), + ), + }, + ) + + with pytest.raises(ValueError, match="duplicate send_id 1 on bus 'can'"): + spec.validate() + + +def test_arm_adapter_reports_limits_and_modes() -> None: + adapter = DamiaoArmAdapter.from_arm_spec(arm_spec=_arm_spec()) + + assert adapter.get_dof() == 2 + assert adapter.get_limits().position_lower == [-1.0, -2.0] + assert adapter.set_control_mode(ControlMode.TORQUE) is True + assert adapter.set_control_mode(ControlMode.VELOCITY) is False + + +def test_arm_adapter_uses_fake_runtime_for_startup_hold(mocker) -> None: + runtime = _FakeRuntime() + adapter = DamiaoArmAdapter.from_arm_spec(arm_spec=_arm_spec()) + mocker.patch.object(adapter, "_create_runtime", return_value=runtime) + + assert adapter.connect() is True + assert adapter.write_enable(True) is True + + assert runtime.writes[-1] == ( + "arm", + [0.4, -0.4], + [0.0, 0.0], + [5.0, 6.0], + [0.1, 0.2], + [0.0, 0.0], + ) + + +def test_arm_adapter_passes_gravity_model_override_to_runtime(mocker) -> None: + runtime = _FakeRuntime() + adapter = DamiaoArmAdapter.from_arm_spec( + arm_spec=_arm_spec(), + gravity_model_path="override.urdf", + ) + mocker.patch.object(adapter, "_create_runtime", return_value=runtime) + + assert adapter.connect() is True + + assert runtime.loaded_gravity_models == [("arm", "override.urdf")] + + +def test_whole_body_requires_all_group_states(mocker) -> None: + runtime = _FakeRuntime(fresh=False) + adapter = DamiaoWholeBodyAdapter(robot_spec=_whole_body_spec(), group_names=("left", "right")) + mocker.patch.object(adapter, "_create_runtime", return_value=runtime) + + assert adapter.connect() is False + assert adapter.has_motor_states() is False + assert adapter.write_joint_positions([0.0, 0.0]) is False + assert runtime.writes == [] + + +def test_whole_body_rejects_out_of_limit_frame_without_partial_send( + mocker, +) -> None: + runtime = _FakeRuntime() + adapter = DamiaoWholeBodyAdapter(robot_spec=_whole_body_spec(), group_names=("left", "right")) + mocker.patch.object(adapter, "_create_runtime", return_value=runtime) + + assert adapter.connect() is True + runtime.writes.clear() + assert adapter.write_joint_positions([0.0, 3.0]) is False + assert runtime.writes == [] + + +def test_whole_body_position_write_splits_groups(mocker) -> None: + runtime = _FakeRuntime() + adapter = DamiaoWholeBodyAdapter(robot_spec=_whole_body_spec(), group_names=("left", "right")) + mocker.patch.object(adapter, "_create_runtime", return_value=runtime) + + assert adapter.connect() is True + runtime.writes.clear() + assert adapter.write_joint_positions([0.5, -0.5]) is True + + assert runtime.batched_calls == 2 + assert runtime.writes == [ + ("left", [0.5], [0.0], [5.0], [0.1], [0.0]), + ("right", [-0.5], [0.0], [6.0], [0.2], [0.0]), + ] + + +def test_whole_body_connect_sends_current_position_hold(mocker) -> None: + runtime = _FakeRuntime() + adapter = DamiaoWholeBodyAdapter(robot_spec=_whole_body_spec(), group_names=("left", "right")) + mocker.patch.object(adapter, "_create_runtime", return_value=runtime) + + assert adapter.connect() is True + + assert runtime.writes == [ + ("left", [0.1], [0.0], [5.0], [0.1], [0.0]), + ("right", [-0.1], [0.0], [6.0], [0.2], [0.0]), + ] diff --git a/dimos/hardware/damiao/whole_body_adapter.py b/dimos/hardware/damiao/whole_body_adapter.py new file mode 100644 index 0000000000..397df28580 --- /dev/null +++ b/dimos/hardware/damiao/whole_body_adapter.py @@ -0,0 +1,251 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import numpy as np + +from dimos.hardware.damiao.runtime import ( + _DEFAULT_STATE_CACHE_TTL_S, + _DEFAULT_TICK_DEADLINE_US, + DamiaoBindingUnavailableError, + DamiaoRobotRuntime, +) +from dimos.hardware.damiao.specs import DamiaoRobotSpec +from dimos.hardware.whole_body.spec import IMUState, MotorCommand, MotorState +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + + +def _dynamic_attr(value: object, name: str) -> Any: + return getattr(value, name) + + +class DamiaoWholeBodyAdapter: + """Position-level WholeBodyAdapter facade over ordered Damiao groups.""" + + _adapter_type: str = "damiao_whole_body" + _binding_error_type: type[RuntimeError] = DamiaoBindingUnavailableError + + def __init__( + self, + *, + robot_spec: DamiaoRobotSpec, + group_names: Sequence[str], + dof: int | None = None, + hardware_id: str = "body", + gravity_comp: bool = True, + use_mock_bus: bool = False, + tick_deadline_us: int = _DEFAULT_TICK_DEADLINE_US, + state_cache_ttl_s: float = _DEFAULT_STATE_CACHE_TTL_S, + ) -> None: + robot_spec.validate() + if not group_names: + raise ValueError("DamiaoWholeBodyAdapter requires at least one group") + unknown_groups = [name for name in group_names if name not in robot_spec.groups] + if unknown_groups: + raise ValueError(f"unknown Damiao groups: {unknown_groups}") + self._robot_spec = robot_spec + self._group_names = tuple(group_names) + self._hardware_id = hardware_id + self._gravity_comp = gravity_comp + self._use_mock_bus = use_mock_bus + self._tick_deadline_us = tick_deadline_us + self._state_cache_ttl_s = state_cache_ttl_s + self._dof = sum(robot_spec.groups[name].dof for name in self._group_names) + if dof is not None and dof != self._dof: + raise ValueError(f"{type(self).__name__} supports {self._dof} DOF (got {dof})") + self._position_lower = [ + value + for group_name in self._group_names + for value in robot_spec.groups[group_name].position_lower + ] + self._position_upper = [ + value + for group_name in self._group_names + for value in robot_spec.groups[group_name].position_upper + ] + self._kp = [ + value for group_name in self._group_names for value in robot_spec.groups[group_name].kp + ] + self._kd = [ + value for group_name in self._group_names for value in robot_spec.groups[group_name].kd + ] + self._runtime: DamiaoRobotRuntime | None = None + self._connected = False + self._enabled = False + self._pin_models: dict[str, object] = {} + self._pin_data: dict[str, object] = {} + + @property + def joint_names(self) -> tuple[str, ...]: + return self._robot_spec.group_joint_names(self._group_names) + + def _create_runtime(self) -> DamiaoRobotRuntime: + return DamiaoRobotRuntime( + robot_spec=self._robot_spec, + adapter_type=self._adapter_type, + binding_error_type=self._binding_error_type, + use_mock_bus=self._use_mock_bus, + tick_deadline_us=self._tick_deadline_us, + state_cache_ttl_s=self._state_cache_ttl_s, + ) + + def connect(self) -> bool: + try: + runtime = self._create_runtime() + if not runtime.connect(): + return False + self._runtime = runtime + self._load_gravity_models() + self._connected = True + # Whole-body v1 intentionally exposes no optional lifecycle hooks, + # so make the position surface usable after coordinator connect(). + self._enabled = runtime.enable() + if not self._enabled: + self.disconnect() + return False + current_positions = [state.q for state in self.read_motor_states()] + if not self.write_joint_positions(current_positions): + logger.error("damiao whole-body startup hold failed", hardware_id=self._hardware_id) + self.disconnect() + return False + except self._binding_error_type: + raise + except Exception: + logger.exception( + "damiao whole-body adapter connect failed", hardware_id=self._hardware_id + ) + self.disconnect() + return False + return True + + def disconnect(self) -> None: + if self._runtime is not None: + self._runtime.disconnect() + self._runtime = None + self._connected = False + self._enabled = False + self._pin_models = {} + self._pin_data = {} + + def is_connected(self) -> bool: + return self._connected + + def has_motor_states(self) -> bool: + if self._runtime is None: + return False + return self._runtime.has_group_states(self._group_names) + + def read_motor_states(self) -> list[MotorState]: + if self._runtime is None: + raise RuntimeError(f"{type(self).__name__} is not connected") + group_states = self._runtime.read_group_states(self._group_names) + states: list[MotorState] = [] + for group_state in group_states: + states.extend( + MotorState(q=q, dq=dq, tau=tau) + for q, dq, tau in zip(group_state.q, group_state.dq, group_state.tau, strict=True) + ) + if len(states) != self._dof: + raise RuntimeError(f"expected {self._dof} motor states, got {len(states)}") + return states + + def read_imu(self) -> IMUState: + return IMUState() + + def write_joint_positions(self, positions: Sequence[float]) -> bool: + """Write a full ordered position frame using configured PD gains.""" + + if self._runtime is None or not self._enabled or len(positions) != self._dof: + return False + q_all = [float(value) for value in positions] + if not self._within_limits(q_all): + return False + if not self.has_motor_states(): + return False + + offset = 0 + frames: dict[ + str, tuple[list[float], list[float], list[float], list[float], list[float]] + ] = {} + for group_name in self._group_names: + group_spec = self._robot_spec.groups[group_name] + width = group_spec.dof + q = q_all[offset : offset + width] + tau = ( + self.compute_gravity_torques(group_name, q) if self._gravity_comp else [0.0] * width + ) + frames[group_name] = (q, [0.0] * width, list(group_spec.kp), list(group_spec.kd), tau) + offset += width + return self._runtime.write_groups_mit_commands(frames) + + def write_motor_commands(self, commands: list[MotorCommand]) -> bool: + raise NotImplementedError( + "TODO: Implement Damiao raw MotorCommand support after the Damiao whole-body " + "command interface is refactored." + ) + + def _within_limits(self, positions: Sequence[float]) -> bool: + for value, lower, upper in zip( + positions, self._position_lower, self._position_upper, strict=True + ): + if value < lower or value > upper: + logger.warning( + "damiao whole-body position outside limits", + hardware_id=self._hardware_id, + value=value, + lower=lower, + upper=upper, + ) + return False + return True + + def _load_gravity_models(self) -> None: + if not self._gravity_comp or self._runtime is None: + return + for group_name in self._group_names: + loaded = self._runtime.load_gravity_model(group_name) + if loaded is None: + continue + self._pin_models[group_name], self._pin_data[group_name] = loaded + + def compute_gravity_torques(self, group_name: str, q: list[float]) -> list[float]: + group_spec = self._robot_spec.groups[group_name] + if group_name not in self._pin_models or group_name not in self._pin_data: + return [0.0] * group_spec.dof + if len(q) != group_spec.dof: + raise ValueError(f"q length does not match dof for group {group_name!r}") + import pinocchio # type: ignore[import-not-found] + + compute_generalized_gravity = _dynamic_attr(pinocchio, "computeGeneralizedGravity") + tau = compute_generalized_gravity( + self._pin_models[group_name], + self._pin_data[group_name], + np.array(q, dtype=np.float64), + ) + values = [float(tau[i]) for i in range(group_spec.dof)] + if group_spec.gravity_torque_limits is None: + return values + return [ + float(np.clip(value, -limit, limit)) + for value, limit in zip(values, group_spec.gravity_torque_limits, strict=False) + ] + + +__all__ = ["DamiaoWholeBodyAdapter"] diff --git a/dimos/hardware/manipulators/damiao/base_adapter.py b/dimos/hardware/manipulators/damiao/base_adapter.py deleted file mode 100644 index 35491f6956..0000000000 --- a/dimos/hardware/manipulators/damiao/base_adapter.py +++ /dev/null @@ -1,584 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import importlib -from pathlib import Path -import time -from typing import Any, cast - -import numpy as np - -from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec -from dimos.hardware.manipulators.spec import ControlMode, JointLimits, ManipulatorInfo -from dimos.utils.logging_config import setup_logger - -logger = setup_logger() - -# Shared adapter defaults. Subclasses reference these so the base and its -# subclasses can't drift apart. -_DEFAULT_TICK_DEADLINE_US = 1_000 -_DEFAULT_STATE_CACHE_TTL_S = 0.002 -# Conventional first SocketCAN interface; used when no address is configured. -_DEFAULT_ADDRESS = "can0" -# Position of each control mode in declaration order, reported by read_state(). -_CONTROL_MODE_INDEX = {mode: index for index, mode in enumerate(ControlMode)} - -_can_motor_control: Any | None -_damiao: Any | None - -try: - _can_motor_control = importlib.import_module("can_motor_control") - _damiao = importlib.import_module("can_motor_control.damiao") -except ImportError as exc: - _can_motor_control = None - _damiao = None - _can_motor_control_import_error: ImportError | None = exc -else: - _can_motor_control_import_error = None - - -class DamiaoBindingUnavailableError(RuntimeError): - pass - - -def _ensure_can_motor_control( - *, - adapter_type: str, - error_type: type[RuntimeError] = DamiaoBindingUnavailableError, -) -> None: - if _can_motor_control_import_error is not None: - raise error_type( - f"The selected '{adapter_type}' adapter requires the Rust-backed " - "can-motor-control Python binding in the active environment. Install " - f"dimos[manipulation] before selecting adapter_type='{adapter_type}'." - ) from _can_motor_control_import_error - - -def _dynamic_attr(value: object, name: str) -> Any: - return getattr(value, name) - - -def _resolve_motor_type(motor_type: object) -> object: - assert _damiao is not None - if isinstance(motor_type, str): - try: - return getattr(_damiao.MotorType, motor_type) - except AttributeError as exc: - raise ValueError(f"Unknown Damiao motor type {motor_type!r}") from exc - if not isinstance(motor_type, int): - return motor_type - for name in dir(_damiao.MotorType): - if name.startswith("_"): - continue - candidate = getattr(_damiao.MotorType, name) - try: - candidate_value = int(candidate) - except (TypeError, ValueError): - continue - if candidate_value == motor_type: - return candidate - raise ValueError(f"Unknown Damiao motor type value {motor_type!r}") - - -class DamiaoArmAdapterBase: - """Shared DimOS adapter behavior for Damiao-based manipulators.""" - - _adapter_type: str = "damiao" - _binding_error_type: type[RuntimeError] = DamiaoBindingUnavailableError - _supported_control_modes: tuple[ControlMode, ...] = ( - ControlMode.POSITION, - ControlMode.SERVO_POSITION, - ControlMode.TORQUE, - ) - - def __init__( - self, - *, - arm_spec: DamiaoArmSpec, - dof: int | None = None, - hardware_id: str = "arm", - kp: list[float] | None = None, - kd: list[float] | None = None, - gravity_comp: bool = True, - gravity_model_path: str | Path | None = None, - gravity_torque_limits: list[float] | tuple[float, ...] | None = None, - supported_control_modes: tuple[ControlMode, ...] | None = None, - address: str | Path | None = _DEFAULT_ADDRESS, - config_path: str | Path | None = None, - use_mock_bus: bool = False, - tick_deadline_us: int = _DEFAULT_TICK_DEADLINE_US, - state_cache_ttl_s: float = _DEFAULT_STATE_CACHE_TTL_S, - ) -> None: - arm_spec.validate() - if dof is not None and dof != arm_spec.dof: - raise ValueError(f"{type(self).__name__} only supports {arm_spec.dof} DOF (got {dof})") - self._arm_spec = arm_spec - self._hardware_id = hardware_id - self._dof = arm_spec.dof - self._motor_specs = list(arm_spec.motors) - self._position_lower = list(arm_spec.position_lower) - self._position_upper = list(arm_spec.position_upper) - self._velocity_max = list(arm_spec.velocity_max) - self._kp = list(kp) if kp is not None else list(arm_spec.kp) - self._kd = list(kd) if kd is not None else list(arm_spec.kd) - self._validate_length("kp", self._kp) - self._validate_length("kd", self._kd) - self._gravity_comp = gravity_comp - resolved_gravity_model = ( - gravity_model_path if gravity_model_path is not None else arm_spec.gravity_model_path - ) - self._gravity_model_path = ( - str(resolved_gravity_model) if resolved_gravity_model is not None else None - ) - resolved_torque_limits = ( - gravity_torque_limits - if gravity_torque_limits is not None - else arm_spec.gravity_torque_limits - ) - self._gravity_torque_limits = ( - list(resolved_torque_limits) if resolved_torque_limits is not None else None - ) - if self._gravity_torque_limits is not None: - self._validate_length("gravity_torque_limits", self._gravity_torque_limits) - self._supported_control_modes = ( - supported_control_modes - if supported_control_modes is not None - else type(self)._supported_control_modes - ) - self._control_mode = ControlMode.POSITION - self._enabled = False - self._last_positions: list[float] | None = None - self._pin_model = None - self._pin_data: object | None = None - self._address = str(address) if address is not None else _DEFAULT_ADDRESS - self._config_path = str(config_path) if config_path is not None else None - self._arm_name = arm_spec.arm_name - self._bus_name = arm_spec.bus_name - self._fd = arm_spec.fd - self._use_mock_bus = use_mock_bus - self._tick_deadline_us = tick_deadline_us - self._state_cache_ttl_s = state_cache_ttl_s - self._robot: Any | None = None - self._arm: Any | None = None - self._connected = False - self._state_cache: tuple[list[float], list[float], list[float]] | None = None - self._state_cache_time = 0.0 - - def _validate_length(self, name: str, values: list[float]) -> None: - if len(values) != self._dof: - raise ValueError(f"{name} length {len(values)} does not match dof {self._dof}") - - def _validate_command_lengths(self, **commands: list[float]) -> None: - for name, values in commands.items(): - self._validate_length(name, values) - - def _zero_vector(self) -> list[float]: - return [0.0] * self._dof - - def get_info(self) -> ManipulatorInfo: - return ManipulatorInfo( - vendor=self._arm_spec.vendor, - model=self._arm_spec.model, - dof=self._dof, - firmware_version=None, - serial_number=None, - ) - - def get_dof(self) -> int: - return self._dof - - def get_limits(self) -> JointLimits: - return JointLimits( - position_lower=list(self._position_lower), - position_upper=list(self._position_upper), - velocity_max=list(self._velocity_max), - ) - - def set_control_mode(self, mode: ControlMode) -> bool: - if mode not in self._supported_control_modes: - return False - self._control_mode = mode - return True - - def get_control_mode(self) -> ControlMode: - return self._control_mode - - def read_enabled(self) -> bool: - return self._enabled - - def read_cartesian_position(self) -> dict[str, float] | None: - return None - - def write_cartesian_position(self, pose: dict[str, float], velocity: float = 1.0) -> bool: - return False - - def read_gripper_position(self) -> float | None: - return None - - def write_gripper_position(self, position: float) -> bool: - return False - - def read_force_torque(self) -> list[float] | None: - return None - - def connect(self) -> bool: - try: - _ensure_can_motor_control( - adapter_type=self._adapter_type, - error_type=self._binding_error_type, - ) - robot = self._build_robot() - robot.connect() - arm = robot[self._arm_name] - if len(arm) != self._dof: - raise RuntimeError( - f"can_motor_control arm group {self._arm_name!r} has {len(arm)} joints, " - f"expected {self._dof}" - ) - self._robot = robot - self._arm = arm - self._load_gravity_model() - self._connected = True - self.refresh_state(force=True) - except self._binding_error_type: - raise - except Exception: - logger.exception( - "damiao adapter connect failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - address=self._address, - ) - self._robot = None - self._arm = None - self._connected = False - return False - return True - - def _build_robot(self) -> Any: - assert _can_motor_control is not None - assert _damiao is not None - if self._config_path is not None: - return _can_motor_control.Robot.from_config(self._config_path) - transport = ( - _can_motor_control.MockCanBus.new_fd(self._address) - if self._use_mock_bus and self._fd - else _can_motor_control.MockCanBus(self._address) - if self._use_mock_bus - else _can_motor_control.SocketCanBus(self._address, fd=self._fd) - ) - codec = _damiao.DamiaoCodec() - binding_specs = [ - _can_motor_control.MotorSpec( - spec.name, - cast("int", _resolve_motor_type(spec.type)), - spec.send_id, - spec.effective_recv_id, - ) - for spec in self._motor_specs - ] - return ( - _can_motor_control.Robot.builder() - .add_bus(self._bus_name, transport, codec) - .add_arm(self._arm_name, bus=self._bus_name, motors=binding_specs) - .build() - ) - - def disconnect(self) -> None: - if self._robot is not None: - try: - self._robot.disable() - except Exception: - logger.warning( - "damiao adapter disable on disconnect failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - exc_info=True, - ) - self._enabled = False - self._connected = False - self._robot = None - self._arm = None - self._state_cache = None - - def is_connected(self) -> bool: - return self._connected - - def refresh_state(self, *, force: bool = False) -> tuple[list[float], list[float], list[float]]: - if self._robot is None or self._arm is None: - raise RuntimeError(f"{type(self).__name__} is not connected") - now = time.monotonic() - if ( - not force - and self._state_cache is not None - and now - self._state_cache_time <= self._state_cache_ttl_s - ): - return self._state_cache - self._arm.refresh() - self._robot.tick(self._tick_deadline_us) - state = ( - self._arm.positions().astype(np.float64).tolist(), - self._arm.velocities().astype(np.float64).tolist(), - self._arm.torques().astype(np.float64).tolist(), - ) - if any(len(values) != self._dof for values in state): - raise RuntimeError("can_motor_control state length does not match configured DOF") - self._state_cache = state - self._state_cache_time = time.monotonic() - self._last_positions = list(state[0]) - return state - - def read_joint_positions(self) -> list[float]: - return list(self.refresh_state()[0]) - - def read_joint_velocities(self) -> list[float]: - return list(self.refresh_state()[1]) - - def read_joint_efforts(self) -> list[float]: - return list(self.refresh_state()[2]) - - def read_state(self) -> dict[str, int]: - return { - "state": 1 if self._enabled else 0, - "mode": _CONTROL_MODE_INDEX[self._control_mode], - } - - def read_error(self) -> tuple[int, str]: - if self._arm is None: - return 0, "" - faults = [] - for spec in self._motor_specs: - fault = getattr(self._arm[spec.name], "fault", None) - if fault is not None: - faults.append(f"{spec.name}: {fault}") - return (0, "") if not faults else (1, "; ".join(faults)) - - def write_joint_positions(self, positions: list[float], velocity: float = 1.0) -> bool: - if ( - self._arm is None - or self._robot is None - or not self._enabled - or len(positions) != self._dof - ): - return False - velocity = max(0.0, min(1.0, velocity)) - if self._gravity_comp: - try: - tau = self.compute_gravity_torques(self.read_joint_positions()) - except RuntimeError: - # State read failed: fall back to zero feed-forward torque, but - # surface it so the dropped gravity term isn't silent. - logger.warning( - "damiao adapter dropping gravity feed-forward; state read failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - exc_info=True, - ) - tau = self._zero_vector() - else: - tau = self._zero_vector() - return self.write_mit_commands( - q=list(positions), - dq=self._zero_vector(), - kp=[kp * velocity for kp in self._kp], - kd=list(self._kd), - tau=tau, - ) - - def write_joint_velocities(self, velocities: list[float]) -> bool: - return False - - def write_joint_torques(self, efforts: list[float]) -> bool: - if ( - self._arm is None - or self._robot is None - or not self._enabled - or len(efforts) != self._dof - ): - return False - q = ( - self._last_positions - if self._last_positions is not None - else self.read_joint_positions() - ) - return self.write_mit_commands( - q=q, dq=self._zero_vector(), kp=self._zero_vector(), kd=self._zero_vector(), tau=efforts - ) - - def write_gravity_compensation(self, damping: float | list[float] = 0.0) -> bool: - try: - q, dq, _ = self.refresh_state(force=True) - except RuntimeError: - logger.warning( - "skipping damiao gravity compensation; state read failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - exc_info=True, - ) - return False - tau = self.compute_gravity_torques(q) - kd = [float(damping)] * self._dof if isinstance(damping, int | float) else list(damping) - return self.write_mit_commands(q=q, dq=dq, kp=self._zero_vector(), kd=kd, tau=tau) - - def write_mit_commands( - self, *, q: list[float], dq: list[float], kp: list[float], kd: list[float], tau: list[float] - ) -> bool: - if self._arm is None or self._robot is None or not self._enabled: - return False - self._validate_command_lengths(q=q, dq=dq, kp=kp, kd=kd, tau=tau) - # MIT command columns are (kp, kd, q, dq, tau) per joint. - self._arm.mit_control(np.column_stack([kp, kd, q, dq, tau]).astype(np.float64)) - self._robot.tick(self._tick_deadline_us) - self._state_cache = None - self._last_positions = list(q) - self._control_mode = ( - ControlMode.TORQUE if all(k == 0.0 for k in kp) else ControlMode.POSITION - ) - return True - - def write_stop(self) -> bool: - if self._arm is None or self._robot is None: - return False - if self._gravity_comp and self._enabled: - try: - q_now = self.read_joint_positions() - except RuntimeError: - try: - self._robot.disable() - except Exception: - logger.warning( - "damiao adapter stop disable failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - exc_info=True, - ) - else: - self._enabled = False - return False - return self.write_mit_commands( - q=q_now, - dq=self._zero_vector(), - kp=list(self._kp), - kd=list(self._kd), - tau=self.compute_gravity_torques(q_now), - ) - try: - self._robot.disable() - except Exception: - logger.warning( - "damiao adapter stop disable failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - exc_info=True, - ) - return False - self._enabled = False - return True - - def write_enable(self, enable: bool) -> bool: - if self._robot is None: - return False - try: - self._robot.enable() if enable else self._robot.disable() - except Exception: - logger.exception( - "damiao adapter enable failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - enable=enable, - ) - return False - self._enabled = enable - if enable: - positions = self.read_joint_positions() - if not self.write_joint_positions(positions): - logger.error( - "damiao adapter startup hold failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - ) - self._enabled = False - try: - self._robot.disable() - except Exception: - logger.warning( - "damiao adapter startup hold disable failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - exc_info=True, - ) - return False - return True - - def write_clear_errors(self) -> bool: - if self._robot is None: - return False - try: - self._robot.disable() - self._robot.enable() - except Exception: - logger.exception( - "damiao adapter clear errors failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - ) - return False - self._enabled = True - positions = self.read_joint_positions() - if not self.write_joint_positions(positions): - logger.error( - "damiao adapter clear-error hold failed", - adapter=type(self).__name__, - hardware_id=self._hardware_id, - ) - return False - return True - - def _load_gravity_model(self) -> None: - if not self._gravity_comp or self._gravity_model_path is None: - return - # Lazy import: pinocchio is a heavy optional dep only needed when - # gravity compensation is enabled, so keep it out of module import. - import pinocchio - - build_model_from_urdf = _dynamic_attr(pinocchio, "buildModelFromUrdf") - self._pin_model = build_model_from_urdf(self._gravity_model_path) - self._pin_data = _dynamic_attr(self._pin_model, "createData")() - - def compute_gravity_torques(self, q: list[float]) -> list[float]: - self._validate_length("q", q) - if self._pin_model is None or self._pin_data is None: - return [0.0] * self._dof - # Lazy import: pinocchio is a heavy optional dep only needed when - # gravity compensation is enabled, so keep it out of module import. - import pinocchio - - compute_generalized_gravity = _dynamic_attr(pinocchio, "computeGeneralizedGravity") - tau = compute_generalized_gravity( - self._pin_model, - self._pin_data, - np.array(q, dtype=np.float64), - ) - values = [float(tau[i]) for i in range(self._dof)] - if self._gravity_torque_limits is None: - return values - return [ - float(np.clip(value, -limit, limit)) - for value, limit in zip(values, self._gravity_torque_limits, strict=False) - ] - - -__all__ = ["DamiaoArmAdapterBase", "DamiaoBindingUnavailableError"] diff --git a/dimos/hardware/manipulators/damiao/test_base_adapter.py b/dimos/hardware/manipulators/damiao/test_base_adapter.py deleted file mode 100644 index ccf4a7de69..0000000000 --- a/dimos/hardware/manipulators/damiao/test_base_adapter.py +++ /dev/null @@ -1,188 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import pytest - -from dimos.hardware.manipulators.damiao.base_adapter import DamiaoArmAdapterBase -from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec -from dimos.hardware.manipulators.spec import ControlMode - - -class _FakeRobot: - def __init__(self) -> None: - self.enable_calls: int = 0 - self.disable_calls: int = 0 - - def enable(self) -> None: - self.enable_calls += 1 - - def disable(self) -> None: - self.disable_calls += 1 - - -def _arm_spec() -> DamiaoArmSpec: - return DamiaoArmSpec( - name="test_damiao", - vendor="Damiao", - model="TestArm", - motors=( - DamiaoMotorSpec("j1", "DM4310", 0x01, 0x11), - DamiaoMotorSpec("j2", "DM4310", 0x02, 0x12), - ), - position_lower=(-1.0, -2.0), - position_upper=(1.0, 2.0), - velocity_max=(3.0, 4.0), - kp=(5.0, 6.0), - kd=(0.1, 0.2), - gravity_torque_limits=(7.0, 8.0), - ) - - -def _attach_robot( - adapter: DamiaoArmAdapterBase, - robot: _FakeRobot, - *, - arm: object | None = None, - enabled: bool | None = None, -) -> None: - adapter._robot = robot - if arm is not None: - adapter._arm = arm - if enabled is not None: - adapter._enabled = enabled - - -def test_arm_spec_exposes_joint_order_for_backend_commands() -> None: - spec = _arm_spec() - - assert spec.joint_names == ("j1", "j2") - assert [motor.send_id for motor in spec.motors] == [0x01, 0x02] - - -def test_arm_spec_rejects_duplicate_ids() -> None: - with pytest.raises(ValueError, match="duplicate send_id"): - DamiaoArmSpec( - name="bad", - vendor="Damiao", - model="BadArm", - motors=( - DamiaoMotorSpec("j1", "DM4310", 0x01, 0x11), - DamiaoMotorSpec("j2", "DM4310", 0x01, 0x12), - ), - position_lower=(-1.0, -2.0), - position_upper=(1.0, 2.0), - velocity_max=(3.0, 4.0), - kp=(5.0, 6.0), - kd=(0.1, 0.2), - ).validate() - - -def test_arm_spec_rejects_length_mismatch() -> None: - with pytest.raises(ValueError, match="kp length 1 does not match dof 2"): - DamiaoArmSpec( - name="bad", - vendor="Damiao", - model="BadArm", - motors=( - DamiaoMotorSpec("j1", "DM4310", 0x01, 0x11), - DamiaoMotorSpec("j2", "DM4310", 0x02, 0x12), - ), - position_lower=(-1.0, -2.0), - position_upper=(1.0, 2.0), - velocity_max=(3.0, 4.0), - kp=(5.0,), - kd=(0.1, 0.2), - ).validate() - - -def test_base_adapter_reports_limits_and_accepts_supported_mode() -> None: - adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec()) - - assert adapter.get_dof() == 2 - limits = adapter.get_limits() - assert limits.position_lower == [-1.0, -2.0] - assert limits.position_upper == [1.0, 2.0] - assert adapter.set_control_mode(ControlMode.TORQUE) is True - assert adapter.get_control_mode() == ControlMode.TORQUE - assert adapter.set_control_mode(ControlMode.VELOCITY) is False - - -def test_write_stop_gravity_comp_holds_current_position(monkeypatch: pytest.MonkeyPatch) -> None: - adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec(), gravity_comp=True) - robot = _FakeRobot() - _attach_robot(adapter, robot, arm=object(), enabled=True) - captured_commands: dict[str, list[float]] = {} - - def read_positions() -> list[float]: - return [0.1, -0.2] - - def write_mit_commands( - *, q: list[float], dq: list[float], kp: list[float], kd: list[float], tau: list[float] - ) -> bool: - captured_commands.update(q=q, dq=dq, kp=kp, kd=kd, tau=tau) - return True - - monkeypatch.setattr(adapter, "read_joint_positions", read_positions) - monkeypatch.setattr(adapter, "write_mit_commands", write_mit_commands) - - assert adapter.write_stop() is True - assert captured_commands == { - "q": [0.1, -0.2], - "dq": [0.0, 0.0], - "kp": [5.0, 6.0], - "kd": [0.1, 0.2], - "tau": [0.0, 0.0], - } - assert robot.disable_calls == 0 - - -def test_write_stop_gravity_comp_read_failure_disables_robot( - monkeypatch: pytest.MonkeyPatch, -) -> None: - adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec(), gravity_comp=True) - robot = _FakeRobot() - _attach_robot(adapter, robot, arm=object(), enabled=True) - - def read_positions() -> list[float]: - raise RuntimeError("state unavailable") - - monkeypatch.setattr(adapter, "read_joint_positions", read_positions) - - assert adapter.write_stop() is False - assert robot.disable_calls == 1 - assert adapter.read_enabled() is False - - -def test_write_enable_startup_hold_failure_disables_robot( - monkeypatch: pytest.MonkeyPatch, -) -> None: - adapter = DamiaoArmAdapterBase(arm_spec=_arm_spec(), gravity_comp=True) - robot = _FakeRobot() - _attach_robot(adapter, robot) - - def read_positions() -> list[float]: - return [0.1, -0.2] - - def reject_hold(_positions: list[float], _velocity: float = 1.0) -> bool: - return False - - monkeypatch.setattr(adapter, "read_joint_positions", read_positions) - monkeypatch.setattr(adapter, "write_joint_positions", reject_hold) - - assert adapter.write_enable(True) is False - assert robot.enable_calls == 1 - assert robot.disable_calls == 1 - assert adapter.read_enabled() is False diff --git a/dimos/hardware/manipulators/openarm_rs/adapter.py b/dimos/hardware/manipulators/openarm_rs/adapter.py index 9a70c9c7dc..107cdd9fe7 100644 --- a/dimos/hardware/manipulators/openarm_rs/adapter.py +++ b/dimos/hardware/manipulators/openarm_rs/adapter.py @@ -17,14 +17,14 @@ from pathlib import Path from typing import TYPE_CHECKING -from dimos.hardware.manipulators.damiao.base_adapter import ( +from dimos.hardware.damiao.arm_adapter import DamiaoArmAdapter +from dimos.hardware.damiao.runtime import ( _DEFAULT_ADDRESS, _DEFAULT_STATE_CACHE_TTL_S, _DEFAULT_TICK_DEADLINE_US, - DamiaoArmAdapterBase, DamiaoBindingUnavailableError, ) -from dimos.hardware.manipulators.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec +from dimos.hardware.damiao.specs import DamiaoArmSpec, DamiaoMotorSpec, DamiaoRobotSpec if TYPE_CHECKING: from dimos.hardware.manipulators.registry import AdapterRegistry @@ -34,7 +34,7 @@ class OpenArmRSBindingUnavailableError(DamiaoBindingUnavailableError): pass -class OpenArmRSAdapter(DamiaoArmAdapterBase): +class OpenArmRSAdapter(DamiaoArmAdapter): _adapter_type: str = "openarm_rs" _binding_error_type: type[RuntimeError] = OpenArmRSBindingUnavailableError _DEFAULT_OPENARM_MOTORS: tuple[DamiaoMotorSpec, ...] = ( @@ -109,9 +109,13 @@ def __init__( arm_name=arm_name, fd=canfd if fd is None else fd, ) + robot_spec = DamiaoRobotSpec.from_arm_spec( + arm_spec, + address=str(address) if address is not None else _DEFAULT_ADDRESS, + ) super().__init__( - arm_spec=arm_spec, - address=address, + robot_spec=robot_spec, + group_name=arm_name, hardware_id=hardware_id, config_path=config_path, use_mock_bus=use_mock_bus, diff --git a/dimos/hardware/whole_body/openarm/__init__.py b/dimos/hardware/whole_body/openarm/__init__.py new file mode 100644 index 0000000000..8a519f884c --- /dev/null +++ b/dimos/hardware/whole_body/openarm/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OpenArm whole-body adapters.""" diff --git a/dimos/hardware/whole_body/openarm/adapter.py b/dimos/hardware/whole_body/openarm/adapter.py new file mode 100644 index 0000000000..811337733b --- /dev/null +++ b/dimos/hardware/whole_body/openarm/adapter.py @@ -0,0 +1,169 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from dimos.hardware.damiao.runtime import ( + _DEFAULT_STATE_CACHE_TTL_S, + _DEFAULT_TICK_DEADLINE_US, + DamiaoBindingUnavailableError, +) +from dimos.hardware.damiao.specs import ( + DamiaoBusSpec, + DamiaoJointGroupSpec, + DamiaoMotorSpec, + DamiaoRobotSpec, +) +from dimos.hardware.damiao.whole_body_adapter import DamiaoWholeBodyAdapter +from dimos.utils.data import LfsPath + +if TYPE_CHECKING: + from dimos.hardware.whole_body.registry import WholeBodyAdapterRegistry + + +class OpenArmDualBindingUnavailableError(DamiaoBindingUnavailableError): + pass + + +class OpenArmDualWholeBodyAdapter(DamiaoWholeBodyAdapter): + """Dual-arm OpenArm whole-body adapter over the shared Damiao runtime.""" + + _adapter_type: str = "openarm_dual" + _binding_error_type: type[RuntimeError] = OpenArmDualBindingUnavailableError + + _LEFT_GROUP = "left_arm" + _RIGHT_GROUP = "right_arm" + _DEFAULT_MOTOR_TYPES: tuple[str, ...] = ( + "DM8006", + "DM8006", + "DM4340", + "DM4340", + "DM4310", + "DM4310", + "DM4310", + ) + _DEFAULT_SEND_IDS: tuple[int, ...] = (0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07) + _DEFAULT_RECV_IDS: tuple[int, ...] = (0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17) + _POSITION_LOWER_LEFT: tuple[float, ...] = (-3.45, -3.30, -1.50, -0.01, -1.50, -0.75, -1.50) + _POSITION_UPPER_LEFT: tuple[float, ...] = (1.35, 0.15, 1.50, 2.40, 1.50, 0.75, 1.50) + _POSITION_LOWER_RIGHT: tuple[float, ...] = (-1.35, -0.15, -1.50, -0.01, -1.50, -0.75, -1.50) + _POSITION_UPPER_RIGHT: tuple[float, ...] = (3.45, 3.30, 1.50, 2.40, 1.50, 0.75, 1.50) + _DEFAULT_VELOCITY_MAX: tuple[float, ...] = (45.0, 45.0, 8.0, 8.0, 30.0, 30.0, 30.0) + _DEFAULT_KP: tuple[float, ...] = (70.0, 70.0, 70.0, 60.0, 10.0, 10.0, 10.0) + _DEFAULT_KD: tuple[float, ...] = (2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5) + _OPENARM_PKG = LfsPath("openarm_description") + _LEFT_GRAVITY_MODEL = _OPENARM_PKG / "urdf/robot/openarm_v10_left.urdf" + _RIGHT_GRAVITY_MODEL = _OPENARM_PKG / "urdf/robot/openarm_v10_right.urdf" + + def __init__( + self, + address: str | Path | None = None, + dof: int = 14, + *, + hardware_id: str = "openarm", + domain_id: int = 0, + left_address: str | Path = "can1", + right_address: str | Path = "can0", + canfd: bool = True, + gravity_comp: bool = True, + use_mock_bus: bool = False, + tick_deadline_us: int = _DEFAULT_TICK_DEADLINE_US, + state_cache_ttl_s: float = _DEFAULT_STATE_CACHE_TTL_S, + ) -> None: + del address, domain_id + robot_spec = self._make_robot_spec( + left_address=left_address, + right_address=right_address, + canfd=canfd, + ) + super().__init__( + robot_spec=robot_spec, + group_names=(self._LEFT_GROUP, self._RIGHT_GROUP), + dof=dof, + hardware_id=hardware_id, + gravity_comp=gravity_comp, + use_mock_bus=use_mock_bus, + tick_deadline_us=tick_deadline_us, + state_cache_ttl_s=state_cache_ttl_s, + ) + + @classmethod + def _make_robot_spec( + cls, + *, + left_address: str | Path, + right_address: str | Path, + canfd: bool, + ) -> DamiaoRobotSpec: + return DamiaoRobotSpec( + name="openarm_dual", + vendor="Enactic", + model="OpenArm RS v10 Dual", + buses={ + "left_can": DamiaoBusSpec(address=left_address, fd=canfd), + "right_can": DamiaoBusSpec(address=right_address, fd=canfd), + }, + groups={ + cls._LEFT_GROUP: DamiaoJointGroupSpec( + bus_name="left_can", + motors=cls._motors("openarm_left"), + position_lower=cls._POSITION_LOWER_LEFT, + position_upper=cls._POSITION_UPPER_LEFT, + velocity_max=cls._DEFAULT_VELOCITY_MAX, + kp=cls._DEFAULT_KP, + kd=cls._DEFAULT_KD, + gravity_model_path=cls._LEFT_GRAVITY_MODEL, + ), + cls._RIGHT_GROUP: DamiaoJointGroupSpec( + bus_name="right_can", + motors=cls._motors("openarm_right"), + position_lower=cls._POSITION_LOWER_RIGHT, + position_upper=cls._POSITION_UPPER_RIGHT, + velocity_max=cls._DEFAULT_VELOCITY_MAX, + kp=cls._DEFAULT_KP, + kd=cls._DEFAULT_KD, + gravity_model_path=cls._RIGHT_GRAVITY_MODEL, + ), + }, + requires_binding=True, + ) + + @classmethod + def _motors(cls, prefix: str) -> tuple[DamiaoMotorSpec, ...]: + return tuple( + DamiaoMotorSpec( + name=f"{prefix}_joint{index + 1}", + type=motor_type, + send_id=send_id, + recv_id=recv_id, + ) + for index, (motor_type, send_id, recv_id) in enumerate( + zip( + cls._DEFAULT_MOTOR_TYPES, + cls._DEFAULT_SEND_IDS, + cls._DEFAULT_RECV_IDS, + strict=True, + ) + ) + ) + + +def register(registry: WholeBodyAdapterRegistry) -> None: + registry.register("openarm_dual", OpenArmDualWholeBodyAdapter) + + +__all__ = ["OpenArmDualBindingUnavailableError", "OpenArmDualWholeBodyAdapter", "register"] diff --git a/dimos/hardware/whole_body/openarm/test_adapter.py b/dimos/hardware/whole_body/openarm/test_adapter.py new file mode 100644 index 0000000000..7d3c43a53e --- /dev/null +++ b/dimos/hardware/whole_body/openarm/test_adapter.py @@ -0,0 +1,135 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import importlib + +import pytest + +from dimos.hardware.manipulators.openarm_rs.adapter import ( + OpenArmRSAdapter, + OpenArmRSBindingUnavailableError, +) +import dimos.hardware.whole_body.openarm.adapter as openarm_dual_adapter +from dimos.hardware.whole_body.openarm.adapter import ( + OpenArmDualBindingUnavailableError, + OpenArmDualWholeBodyAdapter, + register, +) +from dimos.hardware.whole_body.registry import WholeBodyAdapterRegistry +from dimos.hardware.whole_body.spec import WholeBodyAdapter + + +def test_openarm_dual_constructs_without_binding() -> None: + adapter = OpenArmDualWholeBodyAdapter(use_mock_bus=True) + + assert isinstance(adapter, WholeBodyAdapter) + assert adapter.joint_names == ( + "openarm_left_joint1", + "openarm_left_joint2", + "openarm_left_joint3", + "openarm_left_joint4", + "openarm_left_joint5", + "openarm_left_joint6", + "openarm_left_joint7", + "openarm_right_joint1", + "openarm_right_joint2", + "openarm_right_joint3", + "openarm_right_joint4", + "openarm_right_joint5", + "openarm_right_joint6", + "openarm_right_joint7", + ) + + +def test_openarm_dual_configures_side_gravity_models() -> None: + adapter = OpenArmDualWholeBodyAdapter(use_mock_bus=True) + + left = adapter._robot_spec.groups["left_arm"] + right = adapter._robot_spec.groups["right_arm"] + assert str(left.gravity_model_path).endswith("openarm_v10_left.urdf") + assert str(right.gravity_model_path).endswith("openarm_v10_right.urdf") + + +def test_openarm_dual_rejects_non_14_dof() -> None: + with pytest.raises(ValueError, match="supports 14 DOF"): + _ = OpenArmDualWholeBodyAdapter(dof=7, use_mock_bus=True) + + +def test_register_preserves_openarm_dual_key() -> None: + registry = WholeBodyAdapterRegistry() + + register(registry) + + assert registry.available() == ["openarm_dual"] + assert isinstance( + registry.create("openarm_dual", use_mock_bus=True), OpenArmDualWholeBodyAdapter + ) + + +def test_openarm_dual_module_import_does_not_probe_binding(mocker) -> None: + mocker.patch( + "dimos.hardware.damiao.runtime.importlib.import_module", + side_effect=AssertionError("binding import must stay lazy"), + ) + + reloaded = importlib.reload(openarm_dual_adapter) + registry = WholeBodyAdapterRegistry() + reloaded.register(registry) + + assert registry.available() == ["openarm_dual"] + + +def test_openarm_rs_preserves_constructor_deployment_options() -> None: + adapter = OpenArmRSAdapter( + address="can9", + config_path="robot.toml", + arm_name="right_arm", + bus_name="right_can", + fd=False, + canfd=True, + side="right", + use_mock_bus=True, + gravity_model_path="override.urdf", + ) + + assert adapter._group_name == "right_arm" + assert adapter._config_path == "robot.toml" + assert adapter._gravity_model_path == "override.urdf" + assert adapter._robot_spec.buses["right_can"].address == "can9" + assert adapter._robot_spec.buses["right_can"].fd is False + assert adapter.get_limits().position_lower[0] == -1.35 + + +def test_openarm_dual_connect_raises_specific_binding_error(mocker) -> None: + adapter = OpenArmDualWholeBodyAdapter(use_mock_bus=True) + mocker.patch( + "dimos.hardware.damiao.runtime.importlib.import_module", + side_effect=ImportError("missing binding"), + ) + + with pytest.raises(OpenArmDualBindingUnavailableError): + adapter.connect() + + +def test_openarm_rs_connect_raises_specific_binding_error(mocker) -> None: + adapter = OpenArmRSAdapter(use_mock_bus=True) + mocker.patch( + "dimos.hardware.damiao.runtime.importlib.import_module", + side_effect=ImportError("missing binding"), + ) + + with pytest.raises(OpenArmRSBindingUnavailableError): + adapter.connect() diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 70708afd93..8e9c0db290 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -73,6 +73,7 @@ "mid360-fastlio-voxels-native": "dimos.hardware.sensors.lidar.fastlio2.fastlio_blueprints:mid360_fastlio_voxels_native", "mid360-pointlio": "dimos.hardware.sensors.lidar.pointlio.pointlio_blueprints:mid360_pointlio", "mid360-pointlio-voxels": "dimos.hardware.sensors.lidar.pointlio.pointlio_blueprints:mid360_pointlio_voxels", + "openarm-dual-whole-body": "dimos.robot.manipulators.openarm.blueprints:openarm_dual_whole_body", "openarm-mock-planner-coordinator": "dimos.robot.manipulators.openarm.blueprints:openarm_mock_planner_coordinator", "openarm-planner-coordinator": "dimos.robot.manipulators.openarm.blueprints:openarm_planner_coordinator", "openarm-rs-planner-coordinator": "dimos.robot.manipulators.openarm.blueprints:openarm_rs_planner_coordinator", diff --git a/dimos/robot/manipulators/openarm/blueprints.py b/dimos/robot/manipulators/openarm/blueprints.py index dfb89e78ce..9e06db5bc4 100644 --- a/dimos/robot/manipulators/openarm/blueprints.py +++ b/dimos/robot/manipulators/openarm/blueprints.py @@ -16,9 +16,12 @@ from __future__ import annotations -from dimos.control.coordinator import ControlCoordinator +from dimos.control.components import HardwareComponent, HardwareType +from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import autoconnect +from dimos.core.transport import LCMTransport from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.msgs.sensor_msgs.JointState import JointState from dimos.robot.catalog.openarm import ( OPENARM_V10_FK_MODEL, OPENARM_V10_RIGHT_MODEL, @@ -76,6 +79,11 @@ adapter_kwargs=_OPENARM_RS_ADAPTER_KWARGS, ) +OPENARM_DUAL_WHOLE_BODY_JOINTS = [ + *[f"openarm/openarm_left_joint{i}" for i in range(1, 8)], + *[f"openarm/openarm_right_joint{i}" for i in range(1, 8)], +] + coordinator_openarm_left = ControlCoordinator.blueprint( hardware=[_left_hw.to_hardware_component()], tasks=[_left_hw.to_task_config()], @@ -104,6 +112,29 @@ } ) +openarm_dual_whole_body = ControlCoordinator.blueprint( + hardware=[ + HardwareComponent( + hardware_id="openarm", + hardware_type=HardwareType.WHOLE_BODY, + joints=OPENARM_DUAL_WHOLE_BODY_JOINTS, + adapter_type="openarm_dual", + adapter_kwargs={ + "left_address": LEFT_CAN, + "right_address": RIGHT_CAN, + "gravity_comp": True, + }, + ) + ], + tasks=[ + TaskConfig( + name="traj_openarm", + type="trajectory", + joint_names=OPENARM_DUAL_WHOLE_BODY_JOINTS, + ) + ], +) + openarm_rs_planner_coordinator = autoconnect( ManipulationModule.blueprint( robots=[_openarm_rs_hw.to_robot_model_config()], @@ -211,6 +242,7 @@ "coordinator_openarm_rs", "keyboard_teleop_openarm", "keyboard_teleop_openarm_mock", + "openarm_dual_whole_body", "openarm_mock_planner_coordinator", "openarm_planner_coordinator", "openarm_rs_planner_coordinator", From c6e7782b77a7846192f0b23aa5b965027d0c0db8 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 19 Jun 2026 23:17:31 -0700 Subject: [PATCH 052/110] feat: add viser planning target sets --- CONTEXT.md | 6 +- dimos/manipulation/manipulation_module.py | 305 ++++++++++- .../planning/kinematics/pink_ik.py | 289 ++++++++-- .../planning/kinematics/test_pink_ik.py | 170 ++++++ dimos/manipulation/test_manipulation_unit.py | 97 ++++ dimos/manipulation/visualization/types.py | 30 +- .../visualization/viser/adapter.py | 87 ++- dimos/manipulation/visualization/viser/gui.py | 512 +++++++++++++----- .../manipulation/visualization/viser/scene.py | 3 + .../manipulation/visualization/viser/state.py | 52 +- .../viser/test_operation_worker.py | 9 +- .../visualization/viser/test_state.py | 3 + .../viser/test_viser_visualization.py | 163 ++++-- docs/capabilities/manipulation/readme.md | 45 ++ .../.openspec.yaml | 2 + .../add-viser-planning-target-set/design.md | 139 +++++ .../add-viser-planning-target-set/docs.md | 32 ++ .../add-viser-planning-target-set/proposal.md | 53 ++ .../manipulation-planning-groups/spec.md | 71 +++ .../specs/viser-planning-target-set/spec.md | 115 ++++ .../add-viser-planning-target-set/tasks.md | 45 ++ 21 files changed, 1991 insertions(+), 237 deletions(-) create mode 100644 openspec/changes/add-viser-planning-target-set/.openspec.yaml create mode 100644 openspec/changes/add-viser-planning-target-set/design.md create mode 100644 openspec/changes/add-viser-planning-target-set/docs.md create mode 100644 openspec/changes/add-viser-planning-target-set/proposal.md create mode 100644 openspec/changes/add-viser-planning-target-set/specs/manipulation-planning-groups/spec.md create mode 100644 openspec/changes/add-viser-planning-target-set/specs/viser-planning-target-set/spec.md create mode 100644 openspec/changes/add-viser-planning-target-set/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md index 52f23f3a7c..eceab2a274 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -32,8 +32,12 @@ _Avoid_: Planning group definition The set of one or more planning groups chosen for a planning request. _Avoid_: Composite group +**Planning Target Set**: +The atomic manipulation UI/planning state built on top of a planning group selection: selected planning groups, target authoring state, combined IK joint target, whole-set feasibility, and generated plan. Per-group UI panels are views into this target set, not independent planning states. +_Avoid_: Independent group cards, per-robot plan state + **Auxiliary Planning Group**: -A planning group selected to participate in a specific planning request without receiving a direct end-effector pose constraint in that request. A planning group may be auxiliary in one request and directly targeted in another. +A planning group selected to participate in a planning target set without receiving a direct end-effector pose target in that request. It is solved, checked, planned, previewed, and executed with the whole target set; it simply has no assigned target gizmo. A planning group may be auxiliary in one request and directly targeted in another. _Avoid_: Joint-only group, intrinsic auxiliary group **Coordinated Planning Problem**: diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index b767ec3289..9e10f2f794 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -76,7 +76,7 @@ NoManipulationVisualizationConfig, ) from dimos.manipulation.visualization.factory import create_manipulation_visualization -from dimos.manipulation.visualization.types import TargetEvaluation +from dimos.manipulation.visualization.types import TargetEvaluation, TargetSetEvaluation from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -1052,6 +1052,309 @@ def evaluate_pose_target(self, pose: Pose, robot_name: RobotName) -> TargetEvalu "collision_free": collision_free, } + def _selected_target_by_name( + self, group_ids: tuple[PlanningGroupID, ...], target_joints: JointState + ) -> dict[str, float] | None: + """Validate and index a global target joint state for a group selection.""" + assert self._world_monitor is not None + selection = self._world_monitor.planning_groups.select(group_ids) + if len(target_joints.name) != len(target_joints.position): + logger.error( + "Target set has %d names but %d positions", + len(target_joints.name), + len(target_joints.position), + ) + return None + try: + assert_global_joint_names(target_joints.name) + except ValueError as exc: + logger.error("Invalid target-set joint names: %s", exc) + return None + + target_by_name = dict(zip(target_joints.name, target_joints.position, strict=True)) + missing = [name for name in selection.joint_names if name not in target_by_name] + extra = set(target_by_name) - set(selection.joint_names) + if missing: + logger.error("Target set missing joints: %s", missing) + return None + if extra: + logger.error("Target set has extra joints: %s", sorted(extra)) + return None + return target_by_name + + def _local_target_states_for_selection( + self, group_ids: tuple[PlanningGroupID, ...], target_joints: JointState + ) -> dict[RobotName, tuple[WorldRobotID, RobotModelConfig, JointState]] | None: + """Project selected global targets to full local robot states for evaluation.""" + assert self._world_monitor is not None + selection = self._world_monitor.planning_groups.select(group_ids) + target_by_name = self._selected_target_by_name(group_ids, target_joints) + if target_by_name is None: + return None + + local_targets: dict[RobotName, tuple[WorldRobotID, RobotModelConfig, JointState]] = {} + for robot_name in selection.robot_names: + robot = self._robots.get(robot_name) + if robot is None: + logger.error("Robot '%s' is not registered", robot_name) + return None + robot_id, config, _ = robot + current = self._world_monitor.get_current_joint_state(robot_id) + if current is None: + logger.error("No joint state for robot '%s'", robot_name) + return None + current_by_name = self._current_positions_by_name(robot_name, current) + if current_by_name is None: + return None + + positions: list[float] = [] + for local_name, global_name in zip( + config.joint_names, + make_global_joint_names(robot_name, config.joint_names), + strict=True, + ): + if global_name in target_by_name: + positions.append(float(target_by_name[global_name])) + elif local_name in current_by_name: + positions.append(float(current_by_name[local_name])) + else: + logger.error("Current state missing joint '%s'", global_name) + return None + local_targets[robot_name] = ( + robot_id, + config, + JointState(name=list(config.joint_names), position=positions), + ) + return local_targets + + def _target_set_poses( + self, + group_ids: tuple[PlanningGroupID, ...], + local_targets: Mapping[RobotName, tuple[WorldRobotID, RobotModelConfig, JointState]], + ) -> dict[PlanningGroupID, PoseStamped | None]: + """Compute pose outputs for pose-targetable groups in a target set.""" + assert self._world_monitor is not None + poses: dict[PlanningGroupID, PoseStamped | None] = {} + for group in self._world_monitor.planning_groups.select(group_ids).groups: + if not group.has_pose_target: + continue + robot_target = local_targets.get(group.robot_name) + if robot_target is None: + poses[group.id] = None + continue + _, _, joint_state = robot_target + try: + poses[group.id] = self._world_monitor.get_group_pose(group.id, joint_state) + except Exception as exc: + logger.warning("Failed to evaluate pose for group '%s': %s", group.id, exc) + poses[group.id] = None + return poses + + def _evaluate_global_target_set( + self, + group_ids: tuple[PlanningGroupID, ...], + target_joints: JointState | None, + *, + status: str = "FEASIBLE", + message: str = "Target set is feasible", + position_error: float = 0.0, + orientation_error: float = 0.0, + ) -> TargetSetEvaluation: + """Evaluate whole-set collision and pose outputs for global target joints.""" + if self._world_monitor is None: + return { + "success": False, + "status": "UNAVAILABLE", + "message": "Planning is not initialized", + "collision_free": False, + "group_ids": group_ids, + "target_joints": None, + } + if target_joints is None: + return { + "success": False, + "status": "NO_TARGET", + "message": "No target joints provided", + "collision_free": False, + "group_ids": group_ids, + "target_joints": None, + } + try: + local_targets = self._local_target_states_for_selection(group_ids, target_joints) + except (KeyError, ValueError) as exc: + return { + "success": False, + "status": "INVALID", + "message": str(exc), + "collision_free": False, + "group_ids": group_ids, + "target_joints": None, + } + if local_targets is None: + return { + "success": False, + "status": "INVALID", + "message": "Invalid target-set joints", + "collision_free": False, + "group_ids": group_ids, + "target_joints": None, + } + + collision_free = True + diagnostics: dict[PlanningGroupID, str] = {} + selection = self._world_monitor.planning_groups.select(group_ids) + for robot_name, (robot_id, _, local_target) in local_targets.items(): + robot_collision_free = self._world_monitor.is_state_valid(robot_id, local_target) + collision_free = collision_free and robot_collision_free + for group in selection.groups: + if group.robot_name == robot_name: + diagnostics[group.id] = ( + "Target is collision-free" + if robot_collision_free + else "Target is in collision" + ) + + return { + "success": collision_free, + "status": status if collision_free else "COLLISION", + "message": message if collision_free else "Target set is in collision", + "collision_free": collision_free, + "group_ids": group_ids, + "target_joints": JointState(target_joints), + "group_diagnostics": diagnostics, + "group_poses": self._target_set_poses(group_ids, local_targets), + "position_error": position_error, + "orientation_error": orientation_error, + } + + def evaluate_joint_target_set( + self, joint_targets: Mapping[PlanningGroupID | PlanningGroup, JointState] + ) -> TargetSetEvaluation: + """Evaluate joint targets for a whole planning target set.""" + if self._world_monitor is None: + return { + "success": False, + "status": "UNAVAILABLE", + "message": "Planning is not initialized", + "collision_free": False, + "target_joints": None, + } + if not joint_targets: + return { + "success": False, + "status": "NO_TARGET", + "message": "No joint targets provided", + "collision_free": False, + "target_joints": None, + } + + group_ids = tuple( + dict.fromkeys(planning_group_id_from_selector(group) for group in joint_targets) + ) + goal_names: list[str] = [] + goal_positions: list[float] = [] + for group_selector, target in joint_targets.items(): + group_id = planning_group_id_from_selector(group_selector) + target_global = self._joint_target_to_global_names(group_id, target) + if target_global is None: + return { + "success": False, + "status": "INVALID", + "message": f"Invalid joint target for '{group_id}'", + "collision_free": False, + "group_ids": group_ids, + "target_joints": None, + } + goal_names.extend(target_global.name) + goal_positions.extend(target_global.position) + + return self._evaluate_global_target_set( + group_ids, + JointState(name=goal_names, position=goal_positions), + ) + + def evaluate_pose_target_set( + self, + pose_targets: Mapping[PlanningGroupID | PlanningGroup, Pose | PoseStamped], + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup] = (), + seed: JointState | None = None, + ) -> TargetSetEvaluation: + """Evaluate pose targets for a whole planning target set using configured IK.""" + if self._world_monitor is None or self._kinematics is None: + return { + "success": False, + "status": "UNAVAILABLE", + "message": "Planning is not initialized", + "collision_free": False, + "target_joints": None, + } + if not pose_targets: + return { + "success": False, + "status": "NO_TARGET", + "message": "No pose targets provided", + "collision_free": False, + "target_joints": None, + } + + def stamped_pose(pose: Pose | PoseStamped) -> PoseStamped: + if isinstance(pose, PoseStamped): + return pose + return PoseStamped( + frame_id="world", position=pose.position, orientation=pose.orientation + ) + + stamped_targets = { + planning_group_id_from_selector(group): stamped_pose(pose) + for group, pose in pose_targets.items() + } + auxiliary_ids = tuple(planning_group_id_from_selector(group) for group in auxiliary_groups) + group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_ids))) + try: + target_groups = { + self._world_monitor.planning_groups.get(group_id): pose + for group_id, pose in stamped_targets.items() + } + auxiliary = tuple( + self._world_monitor.planning_groups.get(group_id) for group_id in auxiliary_ids + ) + ik = self._kinematics.solve_pose_targets( + world=self._world_monitor.world, + pose_targets=target_groups, + auxiliary_groups=auxiliary, + seed=seed or self._selected_joint_state(group_ids), + check_collision=False, + ) + except (KeyError, ValueError) as exc: + return { + "success": False, + "status": "INVALID", + "message": str(exc), + "collision_free": False, + "group_ids": group_ids, + "target_joints": None, + } + + if not ik.is_success() or ik.joint_state is None: + return { + "success": False, + "status": ik.status.name, + "message": ik.message, + "collision_free": False, + "group_ids": group_ids, + "target_joints": None, + "position_error": ik.position_error, + "orientation_error": ik.orientation_error, + } + return self._evaluate_global_target_set( + group_ids, + ik.joint_state, + status=ik.status.name, + message=ik.message, + position_error=ik.position_error, + orientation_error=ik.orientation_error, + ) + @rpc def set_init_joints(self, joint_state: JointState, robot_name: RobotName | None = None) -> bool: """Set the init joint state. diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index bb5dbf62b8..43c0b9b5d9 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -28,10 +28,10 @@ from dimos.manipulation.planning.groups import ( PlanningGroup, PlanningGroupSelection, - filter_joint_state_to_selected_joints, matching_global_joint_name, ) from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig +from dimos.manipulation.planning.planning_identifiers import make_global_joint_name from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import ( @@ -186,7 +186,7 @@ def solve_pose_targets( check_collision: bool = True, max_attempts: int = 10, ) -> IKResult: - """Solve a planning-group pose target and return only selected global joints.""" + """Solve planning-group pose targets and return selected global joints.""" if not pose_targets: return _failure(IKStatus.NO_SOLUTION, "At least one pose target is required") @@ -197,50 +197,129 @@ def solve_pose_targets( except (KeyError, ValueError) as exc: return _failure(IKStatus.NO_SOLUTION, str(exc)) - if len(pose_groups) != 1: - return _failure( - IKStatus.NO_SOLUTION, - "PinkIK supports exactly one pose target per request", - ) - - target_group = pose_groups[0] - if not target_group.has_pose_target or target_group.tip_link is None: - return _failure( - IKStatus.NO_SOLUTION, - f"Planning group '{target_group.id}' has no pose target frame", - ) - - robot_ids = {robot_ids_by_name[group.robot_name] for group in selection.groups} - if len(robot_ids) != 1: - return _failure(IKStatus.NO_SOLUTION, "PinkIK does not support cross-robot pose IK") + groups_by_robot: dict[RobotName, list[PlanningGroup]] = {} + pose_groups_by_robot: dict[RobotName, list[PlanningGroup]] = {} + for group in selection.groups: + groups_by_robot.setdefault(group.robot_name, []).append(group) + for group in pose_groups: + if not group.has_pose_target or group.tip_link is None: + return _failure( + IKStatus.NO_SOLUTION, + f"Planning group '{group.id}' has no pose target frame", + ) + pose_groups_by_robot.setdefault(group.robot_name, []).append(group) + + selected_positions_by_name: dict[str, float] = {} + max_position_error = 0.0 + max_orientation_error = 0.0 + total_iterations = 0 + for robot_name, groups in groups_by_robot.items(): + robot_id = robot_ids_by_name[robot_name] + robot_pose_groups = pose_groups_by_robot.get(robot_name, []) + if robot_pose_groups: + result = self._solve_pose_targets_for_robot( + world=world, + robot_id=robot_id, + pose_targets={group: pose_targets[group] for group in robot_pose_groups}, + seed=_seed_for_robot(world, robot_id, seed), + position_tolerance=position_tolerance, + orientation_tolerance=orientation_tolerance, + check_collision=check_collision, + max_attempts=max_attempts, + ) + if not result.is_success() or result.joint_state is None: + return result + else: + result = IKResult( + status=IKStatus.SUCCESS, + joint_state=_seed_for_robot(world, robot_id, seed), + message="Auxiliary group retained seed state", + ) + joint_state = result.joint_state + if joint_state is None: + return _failure( + IKStatus.NO_SOLUTION, + f"Pink IK result for robot '{robot_name}' has no joint state", + ) - robot_id = robot_ids_by_name[target_group.robot_name] - if seed is None: - with world.scratch_context() as ctx: - seed = world.get_joint_state(ctx, robot_id) + max_position_error = max(max_position_error, result.position_error) + max_orientation_error = max(max_orientation_error, result.orientation_error) + total_iterations += result.iterations + local_positions = dict(zip(joint_state.name, joint_state.position, strict=True)) + for group in groups: + for global_name, local_name in zip( + group.joint_names, group.local_joint_names, strict=True + ): + if local_name not in local_positions: + return _failure( + IKStatus.NO_SOLUTION, + f"Pink IK result for robot '{robot_name}' is missing joint '{local_name}'", + ) + selected_positions_by_name[global_name] = float(local_positions[local_name]) + + selected_positions = [selected_positions_by_name[name] for name in selection.joint_names] + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState( + {"name": list(selection.joint_names), "position": selected_positions} + ), + position_error=max_position_error, + orientation_error=max_orientation_error, + iterations=total_iterations, + message="Pink IK target set solution found", + ) + def _solve_pose_targets_for_robot( + self, + world: WorldSpec, + robot_id: WorldRobotID, + pose_targets: Mapping[PlanningGroup, PoseStamped], + seed: JointState, + position_tolerance: float, + orientation_tolerance: float, + check_collision: bool, + max_attempts: int, + ) -> IKResult: + """Solve one robot's one-or-more frame targets.""" try: - robot_context = self._get_robot_context(world, robot_id, target_group.tip_link) + contexts = [ + self._get_robot_context(world, robot_id, group.tip_link) + for group in pose_targets + if group.tip_link is not None + ] except (FileNotFoundError, ImportError, ValueError) as exc: return _failure(IKStatus.NO_SOLUTION, f"Pink IK model setup failed: {exc}") + config = world.get_robot_config(robot_id) lower_limits, upper_limits = world.get_joint_limits(robot_id) - target_pose = pose_targets[next(iter(pose_targets.keys()))] - target_model = self._target_in_model_frame(world.get_robot_config(robot_id), target_pose) + target_models = [ + self._target_in_model_frame(config, pose) for pose in pose_targets.values() + ] fallback_result: IKResult | None = None for attempt in range(max_attempts): try: - q0 = self._initial_q(robot_context, seed, lower_limits, upper_limits, attempt) - result = self._solve_single( - robot_context=robot_context, - target_model=target_model, - seed_q=q0, - lower_limits=lower_limits, - upper_limits=upper_limits, - position_tolerance=position_tolerance, - orientation_tolerance=orientation_tolerance, - ) + q0 = self._initial_q(contexts[0], seed, lower_limits, upper_limits, attempt) + if len(contexts) == 1: + result = self._solve_single( + robot_context=contexts[0], + target_model=target_models[0], + seed_q=q0, + lower_limits=lower_limits, + upper_limits=upper_limits, + position_tolerance=position_tolerance, + orientation_tolerance=orientation_tolerance, + ) + else: + result = self._solve_multi_frame( + robot_contexts=contexts, + target_models=target_models, + seed_q=q0, + lower_limits=lower_limits, + upper_limits=upper_limits, + position_tolerance=position_tolerance, + orientation_tolerance=orientation_tolerance, + ) except ValueError as exc: return _failure(IKStatus.NO_SOLUTION, f"Pink IK mapping failed: {exc}") except Exception as exc: @@ -250,26 +329,11 @@ def solve_pose_targets( if fallback_result is None: fallback_result = result continue - if check_collision and not world.check_config_collision_free( robot_id, result.joint_state ): fallback_result = _collision_failure(result) continue - - selected_joint_names: list[str] = [] - selected_local_names: list[str] = [] - for group in selection.groups: - selected_joint_names.extend(group.joint_names) - selected_local_names.extend(group.local_joint_names) - try: - result.joint_state = filter_joint_state_to_selected_joints( - result.joint_state, - selected_joint_names, - selected_local_names, - ) - except ValueError as exc: - return _failure(IKStatus.NO_SOLUTION, str(exc)) return result if fallback_result is not None: @@ -357,6 +421,95 @@ def _solve_single( message="Pink IK did not converge within the iteration budget", ) + def _solve_multi_frame( + self, + robot_contexts: Sequence[_PinkRobotContext], + target_models: Sequence[NDArray[np.float64]], + seed_q: NDArray[np.float64], + lower_limits: NDArray[np.float64], + upper_limits: NDArray[np.float64], + position_tolerance: float, + orientation_tolerance: float, + ) -> IKResult: + """Solve multiple frame tasks for one robot model.""" + pink = self._modules.pink + pinocchio = self._modules.pinocchio + primary_context = robot_contexts[0] + configuration = pink.Configuration( + primary_context.model, primary_context.data, seed_q.copy() + ) + + tasks: list[Any] = [] + for context, target_model in zip(robot_contexts, target_models, strict=True): + frame_task = pink.tasks.FrameTask( + context.frame_name, + position_cost=self.config.position_cost, + orientation_cost=self.config.orientation_cost, + lm_damping=self.config.lm_damping, + gain=self.config.gain, + ) + frame_task.set_target(_matrix_to_se3(pinocchio, target_model)) + tasks.append(frame_task) + + if self.config.posture_cost > 0.0: + posture_task = pink.tasks.PostureTask(cost=self.config.posture_cost) + posture_task.set_target_from_configuration(configuration) + tasks.append(posture_task) + + final_position_error = float("inf") + final_orientation_error = float("inf") + for iteration in range(self.config.max_iterations): + position_errors: list[float] = [] + orientation_errors: list[float] = [] + for context, target_model in zip(robot_contexts, target_models, strict=True): + current_pose = self._current_frame_matrix(context, configuration.q) + position_error, orientation_error = compute_pose_error(current_pose, target_model) + position_errors.append(position_error) + orientation_errors.append(orientation_error) + final_position_error = max(position_errors) + final_orientation_error = max(orientation_errors) + if ( + final_position_error <= position_tolerance + and final_orientation_error <= orientation_tolerance + ): + return _success( + primary_context.mapping.dimos_joint_names, + self._q_to_dimos_positions(primary_context, configuration.q), + final_position_error, + final_orientation_error, + iteration + 1, + ) + + velocity = pink.solve_ik( + configuration, + tasks, + self.config.dt, + solver=self.config.solver, + damping=self.config.damping, + safety_break=self.config.safety_break, + ) + configuration.integrate_inplace(velocity, self.config.dt) + + joint_positions = self._q_to_dimos_positions(primary_context, configuration.q) + if not _within_limits(joint_positions, lower_limits, upper_limits): + return IKResult( + status=IKStatus.JOINT_LIMITS, + joint_state=None, + position_error=final_position_error, + orientation_error=final_orientation_error, + iterations=iteration + 1, + message="Pink IK candidate violates DimOS joint limits", + ) + + return IKResult( + status=IKStatus.NO_SOLUTION, + joint_state=None, + position_error=final_position_error, + orientation_error=final_orientation_error, + iterations=self.config.max_iterations, + message="Pink IK did not converge within the iteration budget", + ) + def _get_robot_context( self, world: WorldSpec, robot_id: WorldRobotID, frame_name: str | None = None ) -> _PinkRobotContext: @@ -596,7 +749,7 @@ def _success( ) -> IKResult: return IKResult( status=IKStatus.SUCCESS, - joint_state=JointState(name=joint_names, position=joint_positions.tolist()), + joint_state=JointState({"name": joint_names, "position": joint_positions.tolist()}), position_error=position_error, orientation_error=orientation_error, iterations=iterations, @@ -619,6 +772,40 @@ def _collision_failure(result: IKResult) -> IKResult: ) +def _seed_for_robot( + world: WorldSpec, robot_id: WorldRobotID, seed: JointState | None +) -> JointState: + """Return a full local seed state for one robot from local/global seed input.""" + config = world.get_robot_config(robot_id) + with world.scratch_context() as ctx: + current = world.get_joint_state(ctx, robot_id) + if seed is None: + return JointState(current) + if not seed.name: + if len(seed.position) == len(config.joint_names): + return JointState({"name": list(config.joint_names), "position": list(seed.position)}) + raise ValueError( + f"Seed has {len(seed.position)} positions for robot '{config.name}', " + f"expected {len(config.joint_names)}" + ) + if len(seed.name) != len(seed.position): + raise ValueError(f"Seed has {len(seed.name)} names but {len(seed.position)} positions") + seed_by_name = dict(zip(seed.name, seed.position, strict=True)) + current_by_name = dict(zip(current.name, current.position, strict=True)) + positions: list[float] = [] + for local_name in config.joint_names: + global_name = make_global_joint_name(config.name, local_name) + if local_name in seed_by_name: + positions.append(float(seed_by_name[local_name])) + elif global_name in seed_by_name: + positions.append(float(seed_by_name[global_name])) + elif local_name in current_by_name: + positions.append(float(current_by_name[local_name])) + else: + raise ValueError(f"Seed/current state is missing joint '{local_name}'") + return JointState({"name": list(config.joint_names), "position": positions}) + + def _robot_ids_by_name( world: WorldSpec, robot_names: tuple[RobotName, ...] ) -> dict[RobotName, WorldRobotID]: diff --git a/dimos/manipulation/planning/kinematics/test_pink_ik.py b/dimos/manipulation/planning/kinematics/test_pink_ik.py index 7bde5774c0..bfb0af604b 100644 --- a/dimos/manipulation/planning/kinematics/test_pink_ik.py +++ b/dimos/manipulation/planning/kinematics/test_pink_ik.py @@ -245,6 +245,47 @@ def check_config_collision_free(self, robot_id: str, joint_state: JointState) -> return self.collision_free +class _FakeMultiRobotWorld: + is_finalized = True + + def __init__(self) -> None: + self.configs = { + "left_robot": RobotModelConfig( + name="left", + model_path=Path("/tmp/left.urdf"), + base_pose=PoseStamped( + position=Vector3(), orientation=Quaternion(0.0, 0.0, 0.0, 1.0) + ), + joint_names=["joint_a", "joint_b"], + end_effector_link="tool", + base_link="base", + ), + "right_robot": RobotModelConfig( + name="right", + model_path=Path("/tmp/right.urdf"), + base_pose=PoseStamped( + position=Vector3(), orientation=Quaternion(0.0, 0.0, 0.0, 1.0) + ), + joint_names=["joint_c"], + end_effector_link="tool", + base_link="base", + ), + } + + def get_robot_ids(self) -> list[str]: + return list(self.configs) + + def get_robot_config(self, robot_id: str) -> RobotModelConfig: + return self.configs[robot_id] + + def scratch_context(self) -> nullcontext[None]: + return nullcontext(None) + + def get_joint_state(self, ctx: object, robot_id: str) -> JointState: + config = self.get_robot_config(robot_id) + return JointState(name=list(config.joint_names), position=[0.0] * len(config.joint_names)) + + def test_create_kinematics_pink_missing_dependency_is_actionable( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -443,6 +484,135 @@ def fake_solve_single(**kwargs: object) -> IKResult: assert result.joint_state.position == [0.1, 0.2, 0.3] +def test_solve_pose_targets_same_robot_uses_one_multi_frame_solve( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ik = _pink_ik(converge=True) + wrist_context = _context() + wrist_context.frame_name = "wrist_tool" + wrist_context.frame_id = 1 + tool_context = _context() + ik._robot_contexts = {"robot:wrist_tool": wrist_context, "robot:tool": tool_context} + world = _FakeWorld(collision_free=True) + tool_group = PlanningGroup( + id="arm/tool", + robot_name="arm", + group_name="tool", + joint_names=("arm/joint_c",), + local_joint_names=("joint_c",), + base_link="base", + tip_link="tool", + ) + seen_frames: list[list[str]] = [] + + def fake_solve_multi_frame(**kwargs: object) -> IKResult: + contexts = cast("list[_PinkRobotContext]", kwargs["robot_contexts"]) + seen_frames.append([context.frame_name for context in contexts]) + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState( + {"name": ["joint_a", "joint_b", "joint_c"], "position": [0.1, 0.2, 0.3]} + ), + position_error=0.0, + orientation_error=0.0, + iterations=2, + ) + + monkeypatch.setattr(ik, "_solve_multi_frame", fake_solve_multi_frame) + + result = ik.solve_pose_targets( + world=cast("Any", world), + pose_targets={ + world.groups["arm/wrist"]: PoseStamped( + position=Vector3(0.1, 0.0, 0.0), + orientation=Quaternion(0.0, 0.0, 0.0, 1.0), + ), + tool_group: PoseStamped( + position=Vector3(0.2, 0.0, 0.0), + orientation=Quaternion(0.0, 0.0, 0.0, 1.0), + ), + }, + seed=JointState( + {"name": ["arm/joint_a", "arm/joint_b", "arm/joint_c"], "position": [0.0, 0.0, 0.0]} + ), + check_collision=False, + max_attempts=1, + ) + + assert seen_frames == [["wrist_tool", "tool"]] + assert result.status == IKStatus.SUCCESS + assert result.joint_state is not None + assert result.joint_state.name == ["arm/joint_a", "arm/joint_b", "arm/joint_c"] + assert result.joint_state.position == [0.1, 0.2, 0.3] + + +def test_solve_pose_targets_cross_robot_combines_global_joint_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ik = _pink_ik(converge=True) + world = _FakeMultiRobotWorld() + left_group = PlanningGroup( + id="left/arm", + robot_name="left", + group_name="arm", + joint_names=("left/joint_a",), + local_joint_names=("joint_a",), + base_link="base", + tip_link="tool", + ) + right_group = PlanningGroup( + id="right/arm", + robot_name="right", + group_name="arm", + joint_names=("right/joint_c",), + local_joint_names=("joint_c",), + base_link="base", + tip_link="tool", + ) + seen_robot_ids: list[str] = [] + + def fake_solve_pose_targets_for_robot(**kwargs: object) -> IKResult: + robot_id = str(kwargs["robot_id"]) + seen_robot_ids.append(robot_id) + if robot_id == "left_robot": + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState(name=["joint_a", "joint_b"], position=[1.0, 9.0]), + ) + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState(name=["joint_c"], position=[2.0]), + ) + + monkeypatch.setattr(ik, "_solve_pose_targets_for_robot", fake_solve_pose_targets_for_robot) + + result = ik.solve_pose_targets( + world=cast("Any", world), + pose_targets={ + left_group: PoseStamped( + position=Vector3(0.1, 0.0, 0.0), + orientation=Quaternion(0.0, 0.0, 0.0, 1.0), + ), + right_group: PoseStamped( + position=Vector3(0.2, 0.0, 0.0), + orientation=Quaternion(0.0, 0.0, 0.0, 1.0), + ), + }, + seed=JointState( + name=["left/joint_a", "left/joint_b", "right/joint_c"], + position=[0.0, 0.0, 0.0], + ), + check_collision=False, + max_attempts=1, + ) + + assert seen_robot_ids == ["left_robot", "right_robot"] + assert result.status == IKStatus.SUCCESS + assert result.joint_state is not None + assert result.joint_state.name == ["left/joint_a", "right/joint_c"] + assert result.joint_state.position == [1.0, 2.0] + + def test_solve_retries_after_joint_limit_failure(monkeypatch: pytest.MonkeyPatch) -> None: ik = _pink_ik(converge=True) context = _context() diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index d4141d03b3..5aef347767 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -868,6 +868,103 @@ def test_preview_plan_returns_false_for_missing_inputs(self): assert module.preview_plan() is False +class TestTargetSetEvaluation: + def test_evaluate_joint_target_set_projects_selected_targets_to_full_robot_states(self): + left_config = _make_robot_config("left", ["j1", "j2"], "left_task") + right_config = _make_robot_config("right", ["j1", "j2"], "right_task") + module = _make_module_with_monitor(left_config, right_config) + module._world_monitor.planning_groups = _FakePlanningGroups( + [ + _make_global_group("left", "arm", ["j1"]), + _make_global_group("right", "arm", ["j2"]), + ] + ) + module._world_monitor.get_current_joint_state.side_effect = [ + JointState(name=["j1", "j2"], position=[0.0, 9.0]), + JointState(name=["j1", "j2"], position=[8.0, 0.0]), + ] + module._world_monitor.is_state_valid.return_value = True + left_pose = PoseStamped(position=Vector3(x=1.0), orientation=Quaternion()) + right_pose = PoseStamped(position=Vector3(x=2.0), orientation=Quaternion()) + module._world_monitor.get_group_pose.side_effect = [left_pose, right_pose] + + result = module.evaluate_joint_target_set( + { + "left/arm": JointState(name=["j1"], position=[1.0]), + "right/arm": JointState(name=["j2"], position=[2.0]), + } + ) + + assert result["success"] is True + assert result["collision_free"] is True + assert result["target_joints"] is not None + assert result["target_joints"].name == ["left/j1", "right/j2"] + assert result["target_joints"].position == [1.0, 2.0] + left_target = module._world_monitor.is_state_valid.call_args_list[0].args[1] + right_target = module._world_monitor.is_state_valid.call_args_list[1].args[1] + assert left_target.name == ["j1", "j2"] + assert left_target.position == [1.0, 9.0] + assert right_target.name == ["j1", "j2"] + assert right_target.position == [8.0, 2.0] + assert result["group_poses"] == {"left/arm": left_pose, "right/arm": right_pose} + + def test_evaluate_joint_target_set_reports_collision_per_group(self): + config = _make_robot_config("left", ["j1"], "task") + module = _make_module_with_monitor(config) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1"], position=[0.0] + ) + module._world_monitor.is_state_valid.return_value = False + module._world_monitor.get_group_pose.return_value = None + + result = module.evaluate_joint_target_set( + {"left/manipulator": JointState(name=["j1"], position=[1.0])} + ) + + assert result["success"] is False + assert result["status"] == "COLLISION" + assert result["collision_free"] is False + assert result["message"] == "Target set is in collision" + assert result["group_diagnostics"] == {"left/manipulator": "Target is in collision"} + + def test_evaluate_pose_target_set_uses_whole_set_seed_and_auxiliary_groups(self): + config = _make_robot_config("left", ["j1", "j2"], "task") + arm_group = _make_global_group("left", "arm", ["j1"]) + wrist_group = _make_global_group("left", "wrist", ["j2"]) + module = _make_module_with_monitor(config) + module._world_monitor.planning_groups = _FakePlanningGroups([arm_group, wrist_group]) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1", "j2"], position=[0.1, 0.2] + ) + module._world_monitor.is_state_valid.return_value = True + module._world_monitor.get_group_pose.return_value = PoseStamped( + position=Vector3(x=1.0), orientation=Quaternion() + ) + module._kinematics = MagicMock() + module._kinematics.solve_pose_targets.return_value = IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState(name=["left/j1", "left/j2"], position=[1.0, 2.0]), + message="solved", + position_error=0.01, + orientation_error=0.02, + ) + pose = Pose(position=Vector3(x=0.5), orientation=Quaternion()) + + result = module.evaluate_pose_target_set( + {"left/arm": pose}, auxiliary_groups=("left/wrist",) + ) + + assert result["success"] is True + assert result["target_joints"] is not None + assert result["target_joints"].name == ["left/j1", "left/j2"] + assert result["target_joints"].position == [1.0, 2.0] + _, kwargs = module._kinematics.solve_pose_targets.call_args + assert list(kwargs["pose_targets"]) == [arm_group] + assert kwargs["auxiliary_groups"] == (wrist_group,) + assert kwargs["seed"].name == ["left/j1", "left/j2"] + assert kwargs["seed"].position == [0.1, 0.2] + + class TestGeneratedPlanProjection: def test_selected_joint_state_accepts_local_current_state_names(self): config = _make_robot_config("left", ["j1", "j2"], "task") diff --git a/dimos/manipulation/visualization/types.py b/dimos/manipulation/visualization/types.py index 778c293499..a4c22edcd7 100644 --- a/dimos/manipulation/visualization/types.py +++ b/dimos/manipulation/visualization/types.py @@ -16,7 +16,7 @@ from typing import TypedDict -from dimos.manipulation.planning.spec.models import RobotName, WorldRobotID +from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName, WorldRobotID from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState @@ -33,7 +33,20 @@ class TargetEvaluation(TypedDict, total=False): orientation_error: float -class RobotInfo(TypedDict): +class TargetSetEvaluation(TypedDict, total=False): + success: bool + status: str + message: str + collision_free: bool + group_ids: tuple[PlanningGroupID, ...] + target_joints: JointState | None + group_diagnostics: dict[PlanningGroupID, str] + group_poses: dict[PlanningGroupID, PoseStamped | Pose | None] + position_error: float + orientation_error: float + + +class RobotInfo(TypedDict, total=False): name: RobotName world_robot_id: WorldRobotID joint_names: list[str] @@ -46,3 +59,16 @@ class RobotInfo(TypedDict): home_joints: list[float] | None pre_grasp_offset: float init_joints: list[float] | None + planning_groups: list[PlanningGroupInfo] + + +class PlanningGroupInfo(TypedDict): + id: PlanningGroupID + name: str + robot_name: RobotName + joint_names: list[str] + local_joint_names: list[str] + base_link: str + tip_link: str | None + has_pose_target: bool + source: str diff --git a/dimos/manipulation/visualization/viser/adapter.py b/dimos/manipulation/visualization/viser/adapter.py index 47a1bc616c..3824df6e31 100644 --- a/dimos/manipulation/visualization/viser/adapter.py +++ b/dimos/manipulation/visualization/viser/adapter.py @@ -15,16 +15,22 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING - -from dimos.manipulation.visualization.types import RobotInfo, TargetEvaluation +from typing import TYPE_CHECKING, cast + +from dimos.manipulation.visualization.types import ( + PlanningGroupInfo, + RobotInfo, + TargetEvaluation, + TargetSetEvaluation, +) from dimos.msgs.sensor_msgs.JointState import JointState if TYPE_CHECKING: from dimos.manipulation.manipulation_module import ManipulationModule + from dimos.manipulation.planning.groups import PlanningGroup from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor from dimos.manipulation.planning.spec.config import RobotModelConfig - from dimos.manipulation.planning.spec.models import RobotName, WorldRobotID + from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName, WorldRobotID from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -84,8 +90,31 @@ def get_robot_info(self, robot_name: RobotName) -> RobotInfo | None: "init_joints": None if info["init_joints"] is None else [float(value) for value in info["init_joints"]], + "planning_groups": [ + { + "id": str(group["id"]), + "name": str(group["name"]), + "robot_name": str(info["name"]), + "joint_names": [str(name) for name in group["joint_names"]], + "local_joint_names": [str(name) for name in group["local_joint_names"]], + "base_link": str(group["base_link"]), + "tip_link": None if group["tip_link"] is None else str(group["tip_link"]), + "has_pose_target": bool(group["has_pose_target"]), + "source": str(group["source"]), + } + for group in info.get("planning_groups", []) + ], } + def list_planning_groups(self) -> list[PlanningGroupInfo]: + groups: list[PlanningGroupInfo] = [] + for robot_name in self.list_robots(): + info = self.get_robot_info(robot_name) + if info is None: + continue + groups.extend(info.get("planning_groups", [])) + return groups + def get_init_joints(self, robot_name: RobotName) -> JointState | None: return copy_joint_state(self._module.get_init_joints(robot_name)) @@ -127,6 +156,45 @@ def evaluate_pose_target(self, pose: Pose, robot_name: RobotName) -> TargetEvalu ) return result + def evaluate_joint_target_set( + self, joint_targets: dict[PlanningGroupID, JointState] + ) -> TargetSetEvaluation: + result: TargetSetEvaluation = { + **self._module.evaluate_joint_target_set( + cast( + "dict[PlanningGroupID | PlanningGroup, JointState]", + { + group_id: copy_joint_state(target) or target + for group_id, target in joint_targets.items() + }, + ) + ) + } + target_joints = result.get("target_joints") + result["target_joints"] = copy_joint_state( + target_joints if isinstance(target_joints, JointState) else None + ) + return result + + def evaluate_pose_target_set( + self, + pose_targets: dict[PlanningGroupID, Pose | PoseStamped], + auxiliary_groups: Sequence[PlanningGroupID] = (), + seed: JointState | None = None, + ) -> TargetSetEvaluation: + result: TargetSetEvaluation = { + **self._module.evaluate_pose_target_set( + cast("dict[PlanningGroupID | PlanningGroup, Pose | PoseStamped]", pose_targets), + auxiliary_groups=auxiliary_groups, + seed=copy_joint_state(seed), + ) + } + target_joints = result.get("target_joints") + result["target_joints"] = copy_joint_state( + target_joints if isinstance(target_joints, JointState) else None + ) + return result + def get_module_state(self) -> str: return str(self._module.get_state()) @@ -142,12 +210,23 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: def plan_to_joints(self, joints: JointState, robot_name: RobotName | None = None) -> bool: return self._module.plan_to_joints(joints, robot_name) + def plan_target_set(self, joint_targets: dict[PlanningGroupID, JointState]) -> bool: + return self._module.plan_to_joint_targets( + cast("dict[PlanningGroupID | PlanningGroup, JointState]", joint_targets) + ) + def preview_plan(self, robot_name: RobotName | None = None) -> bool: return self._module.preview_plan(robot_name=robot_name) + def preview_target_set_plan(self) -> bool: + return self._module.preview_plan() + def execute(self, robot_name: RobotName | None = None) -> bool: return self._module.execute(robot_name) + def execute_target_set_plan(self) -> bool: + return self._module.execute() + def cancel(self) -> bool: return self._module.cancel() diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 44cb9f726f..f227536fa5 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -16,7 +16,13 @@ from typing import TypeAlias -from dimos.manipulation.visualization.types import RobotInfo, TargetEvaluation +from dimos.manipulation.planning.spec.models import PlanningGroupID +from dimos.manipulation.visualization.types import ( + PlanningGroupInfo, + RobotInfo, + TargetEvaluation, + TargetSetEvaluation, +) from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig from dimos.manipulation.visualization.viser.runtime import VISER_INSTALL_HINT @@ -88,6 +94,7 @@ def __init__( self._closed = False self._operation_sequence_id = 0 self._suppress_target_callbacks = False + self._default_group_initialized = False self._handles: dict[str, PanelHandle] = {} self._joint_sliders: dict[str, GuiSliderHandle[float]] = {} self._worker = TargetEvaluationWorker( @@ -128,14 +135,19 @@ def refresh(self) -> None: if self._closed: return robots = self.adapter.list_robots() + groups = self.adapter.list_planning_groups() self.state.backend_status = ( BackendConnectionStatus.READY if robots else BackendConnectionStatus.WAITING_FOR_ROBOT ) - if self.state.selected_robot is None and robots: - self.state.selected_robot = robots[0] + if not self.state.selected_group_ids and groups and not self._default_group_initialized: + self.state.selected_group_ids = (str(groups[0]["id"]),) + self.state.selected_robot = str(groups[0]["robot_name"]) self.state.target_status = TargetStatus.EMPTY + self._default_group_initialized = True + self._initialize_selected_group_targets() self._build_joint_sliders() self._sync_robot_dropdown(robots) + self._sync_group_checkboxes(groups) self._refresh_selected_robot_state() self._ensure_scene_controls() self._sync_preset_dropdown() @@ -160,6 +172,9 @@ def _build_panel_controls(self, gui: GuiApi) -> None: ) robot_dropdown.on_update(lambda event: self._select_robot(event.target.value)) self._handles["robot"] = robot_dropdown + select_all_button = gui.add_button("Select all manipulators") + select_all_button.on_click(lambda _: self._select_all_manipulators()) + self._handles["select_all_manipulators"] = select_all_button preset_dropdown = gui.add_dropdown( "Target Preset", options=["Select preset...", "Current"], @@ -218,56 +233,131 @@ def _refresh_selected_robot_state(self) -> None: self.state.error = adapter_error def _ensure_scene_controls(self) -> None: - if self.scene is None or self.state.selected_robot is None: - return - robot_id = self.adapter.robot_id_for_name(self.state.selected_robot) - if robot_id is None: + if self.scene is None: return - ee_control = self.scene.ensure_target_controls(str(robot_id), self._on_transform_update) - if ee_control is not None: - self._handles["ee_control"] = ee_control - if ( - self.state.target_status == TargetStatus.EMPTY - and self.state.current_ee_pose is not None - ): - self.state.cartesian_target = self.state.current_ee_pose - self._suppress_target_callbacks = True - try: - self.scene.set_target_pose(str(robot_id), self.state.current_ee_pose) - finally: - self._suppress_target_callbacks = False + groups = self._group_info_by_id() + selected = set(self.state.selected_group_ids) + active_pose_groups = { + group_id + for group_id in selected + if (group := groups.get(group_id)) is not None + and bool(group["has_pose_target"]) + and group_id not in self.state.auxiliary_group_ids + } + for key in [key for key in self._handles if key.startswith("ee_control:")]: + group_id = key.split(":", 1)[1] + if group_id in active_pose_groups: + continue + handle = self._handles.pop(key) + remove_target_controls = getattr(self.scene, "remove_target_controls", None) + if callable(remove_target_controls): + remove_target_controls(group_id) + else: + remove = getattr(handle, "remove", None) + if callable(remove): + remove() + for group_id in selected: + group = groups.get(group_id) + if group is None or not bool(group["has_pose_target"]): + continue + if group_id in self.state.auxiliary_group_ids: + continue + ee_control = self.scene.ensure_target_controls( + group_id, + lambda target, gid=group_id: self._on_transform_update(gid, target), + ) + if ee_control is not None: + self._handles[f"ee_control:{group_id}"] = ee_control + pose = self.state.group_poses.get(group_id) or self.state.pose_targets.get(group_id) + if pose is not None: + self._suppress_target_callbacks = True + try: + self.scene.set_target_pose(group_id, pose) + finally: + self._suppress_target_callbacks = False def _build_joint_sliders(self) -> None: - if self.state.selected_robot is None: + if not self.state.selected_group_ids: return gui = self.server.gui - config = self.adapter.get_robot_config(self.state.selected_robot) - if config is None: - return - current = self.adapter.get_current_joint_state(self.state.selected_robot) - values = list(current.position) if current is not None else [0.0] * len(config.joint_names) self._clear_joint_sliders() - joint_limits_lower = config.joint_limits_lower - joint_limits_upper = config.joint_limits_upper - for index, joint_name in enumerate(config.joint_names): - lower, upper = DEFAULT_JOINT_LIMITS - if joint_limits_lower is not None and index < len(joint_limits_lower): - lower = joint_limits_lower[index] - if joint_limits_upper is not None and index < len(joint_limits_upper): - upper = joint_limits_upper[index] - handle = gui.add_slider( - joint_name, - min=float(lower), - max=float(upper), - step=0.001, - initial_value=float(values[index] if index < len(values) else 0.0), + groups = self._group_info_by_id() + target_by_name: dict[str, float] = {} + if self.state.target_joints is not None: + target_by_name.update( + zip(self.state.target_joints.name, self.state.target_joints.position, strict=False) ) + for group_id in self.state.selected_group_ids: + group = groups.get(group_id) + if group is None: + continue + config = self.adapter.get_robot_config(str(group["robot_name"])) + current = self.adapter.get_current_joint_state(str(group["robot_name"])) + current_by_name = ( + dict(zip(current.name, current.position, strict=False)) + if current is not None + else {} + ) + joint_limits_lower = config.joint_limits_lower if config is not None else None + joint_limits_upper = config.joint_limits_upper if config is not None else None + for index, (global_name, local_name) in enumerate( + zip(group["joint_names"], group["local_joint_names"], strict=False) + ): + joint_name = str(global_name) + local = str(local_name) + value = float(target_by_name.get(joint_name, current_by_name.get(local, 0.0))) + lower, upper = DEFAULT_JOINT_LIMITS + if joint_limits_lower is not None and index < len(joint_limits_lower): + lower = joint_limits_lower[index] + if joint_limits_upper is not None and index < len(joint_limits_upper): + upper = joint_limits_upper[index] + handle = gui.add_slider( + f"{group_id}/{local}", + min=float(lower), + max=float(upper), + step=0.001, + initial_value=value, + ) - def on_update(_event: object, name: str = joint_name) -> None: - self._on_joint_slider_update(name) - - handle.on_update(on_update) - self._joint_sliders[joint_name] = handle + def on_update(_event: object, name: str = joint_name) -> None: + self._on_joint_slider_update(name) + + handle.on_update(on_update) + self._joint_sliders[joint_name] = handle + + def _target_set_from_sliders(self) -> dict[PlanningGroupID, JointState] | None: + groups = self._group_info_by_id() + targets: dict[PlanningGroupID, JointState] = {} + for group_id in self.state.selected_group_ids: + group = groups.get(group_id) + if group is None: + self._set_error(f"Unknown planning group: {group_id}") + return None + names = [str(name) for name in group["joint_names"]] + positions: list[float] = [] + for name in names: + handle = self._joint_sliders.get(name) + if handle is None: + self._set_error(f"Missing target slider for {name}") + return None + positions.append(float(handle.value)) + targets[group_id] = JointState({"name": names, "position": positions}) + return targets + + def _split_target_joints_by_group(self, target_joints: JointState) -> None: + groups = self._group_info_by_id() + positions_by_name = dict(zip(target_joints.name, target_joints.position, strict=False)) + self.state.group_joint_targets.clear() + for group_id in self.state.selected_group_ids: + group = groups.get(group_id) + if group is None: + continue + names = [str(name) for name in group["joint_names"]] + if not all(name in positions_by_name for name in names): + continue + self.state.group_joint_targets[group_id] = JointState( + {"name": names, "position": [float(positions_by_name[name]) for name in names]} + ) def _clear_joint_sliders(self) -> None: for handle in self._joint_sliders.values(): @@ -291,9 +381,19 @@ def _select_robot(self, robot_name: str) -> None: self.refresh() return self.state.selected_robot = robot_name or None + groups = [ + group + for group in self.adapter.list_planning_groups() + if str(group["robot_name"]) == self.state.selected_robot + ] + self.state.selected_group_ids = (str(groups[0]["id"]),) if groups else () + self.state.auxiliary_group_ids = () + self.state.group_joint_targets.clear() + self.state.pose_targets.clear() self.state.target_status = TargetStatus.EMPTY self.state.feasibility.status = FeasibilityStatus.UNKNOWN self.state.plan_state = PanelPlanState() + self._initialize_selected_group_targets() self._build_joint_sliders() self._sync_preset_dropdown() self.refresh() @@ -315,6 +415,130 @@ def _sync_robot_dropdown(self, robots: list[str]) -> None: except Exception: logger.warning("Could not set robot dropdown value", exc_info=True) + def _sync_group_checkboxes(self, groups: list[PlanningGroupInfo]) -> None: + seen_keys: set[str] = set() + selected = set(self.state.selected_group_ids) + for group in groups: + group_id = str(group["id"]) + key = f"group:{group_id}" + seen_keys.add(key) + handle = self._handles.get(key) + if handle is None: + handle = self.server.gui.add_checkbox( + f"Group {group_id}", initial_value=group_id in selected + ) + handle.on_update( + lambda event, gid=group_id: self._set_group_selected( + gid, bool(event.target.value) + ) + ) + self._handles[key] = handle + elif hasattr(handle, "value"): + self._set_optional_handle_attr(handle, "value", group_id in selected) + + for key in [key for key in self._handles if key.startswith("group:")]: + if key not in seen_keys: + handle = self._handles.pop(key) + remove = getattr(handle, "remove", None) + if callable(remove): + remove() + + def _set_group_selected(self, group_id: PlanningGroupID, selected: bool) -> None: + current = list(self.state.selected_group_ids) + if selected and group_id not in current: + current.append(group_id) + elif not selected and group_id in current: + current.remove(group_id) + self.state.selected_group_ids = tuple(current) + self.state.auxiliary_group_ids = tuple( + group_id for group_id in self.state.auxiliary_group_ids if group_id in current + ) + self._sync_selected_robot_from_groups() + self._initialize_selected_group_targets() + self.state.mark_plan_stale() + self._build_joint_sliders() + self.refresh() + + def _select_all_manipulators(self) -> None: + groups = self.adapter.list_planning_groups() + manipulator_groups = [ + str(group["id"]) for group in groups if str(group["name"]) == "manipulator" + ] + self.state.selected_group_ids = tuple( + manipulator_groups or [str(group["id"]) for group in groups] + ) + self._sync_selected_robot_from_groups() + self._initialize_selected_group_targets() + self._build_joint_sliders() + self.refresh() + + def _group_info_by_id(self) -> dict[PlanningGroupID, PlanningGroupInfo]: + return {str(group["id"]): group for group in self.adapter.list_planning_groups()} + + def _sync_selected_robot_from_groups(self) -> None: + groups = self._group_info_by_id() + first_group = ( + groups.get(self.state.selected_group_ids[0]) if self.state.selected_group_ids else None + ) + self.state.selected_robot = None if first_group is None else str(first_group["robot_name"]) + + def _initialize_selected_group_targets(self) -> None: + groups = self._group_info_by_id() + for group_id in self.state.selected_group_ids: + if group_id in self.state.group_joint_targets: + continue + group = groups.get(group_id) + if group is None: + continue + current = self.adapter.get_current_joint_state(str(group["robot_name"])) + if current is None: + continue + current_by_name = dict(zip(current.name, current.position, strict=False)) + names = [str(name) for name in group["joint_names"]] + local_names = [str(name) for name in group["local_joint_names"]] + positions = [float(current_by_name.get(local, 0.0)) for local in local_names] + self.state.group_joint_targets[group_id] = JointState( + {"name": names, "position": positions} + ) + if bool(group["has_pose_target"]) and group_id not in self.state.pose_targets: + pose = self.adapter.get_ee_pose(str(group["robot_name"])) + if pose is not None: + self.state.pose_targets[group_id] = pose + self.state.group_poses[group_id] = pose + if self.state.cartesian_target is None: + self.state.cartesian_target = pose + self._refresh_target_joints_from_groups() + + def _refresh_target_joints_from_groups(self) -> None: + names: list[str] = [] + positions: list[float] = [] + for group_id in self.state.selected_group_ids: + target = self.state.group_joint_targets.get(group_id) + if target is None: + continue + names.extend(target.name) + positions.extend(target.position) + self.state.target_joints = ( + JointState({"name": names, "position": positions}) if names else None + ) + + def _current_snapshot_by_group(self) -> dict[PlanningGroupID, list[float]]: + groups = self._group_info_by_id() + snapshot: dict[PlanningGroupID, list[float]] = {} + for group_id in self.state.selected_group_ids: + group = groups.get(group_id) + if group is None: + continue + current = self.adapter.get_current_joint_state(str(group["robot_name"])) + if current is None: + continue + current_by_name = dict(zip(current.name, current.position, strict=False)) + snapshot[group_id] = [ + float(current_by_name.get(str(local_name), 0.0)) + for local_name in group["local_joint_names"] + ] + return snapshot + def _sync_preset_dropdown(self) -> None: handle = self._handles.get("preset") if handle is None or self.state.selected_robot is None: @@ -348,16 +572,35 @@ def _apply_preset(self, preset: str) -> None: return if preset == "Current": current = self.adapter.get_current_joint_state(robot_name) - values = list(current.position) if current is not None else [] + values_by_name = ( + dict(zip(current.name, current.position, strict=False)) + if current is not None + else {} + ) elif preset == "Init": init = self.adapter.get_init_joints(robot_name) - values = list(init.position) if init is not None else [] + values_by_name = ( + dict(zip(init.name, init.position, strict=False)) if init is not None else {} + ) elif preset == "Home": - values = list(config.home_joints or []) + values_by_name = dict(zip(config.joint_names, config.home_joints or [], strict=False)) else: return - self._set_slider_values(config.joint_names, values) - self.state.joint_target = [float(value) for value in values] + groups = [ + group + for group in self.adapter.list_planning_groups() + if group["id"] in self.state.selected_group_ids + and str(group["robot_name"]) == robot_name + ] + for group in groups: + global_names = [str(name) for name in group["joint_names"]] + local_names = [str(name) for name in group["local_joint_names"]] + values = [ + float(values_by_name.get(local_name, values_by_name.get(global_name, 0.0))) + for local_name, global_name in zip(local_names, global_names, strict=False) + ] + self._set_slider_values(global_names, values) + self.state.joint_target = [float(handle.value) for handle in self._joint_sliders.values()] self._submit_joint_target_evaluation() self.refresh() @@ -390,73 +633,86 @@ def _on_joint_slider_update(self, _joint_name: str) -> None: return self._submit_joint_target_evaluation() - def _on_transform_update(self, target: TransformControlsHandle) -> None: + def _on_transform_update( + self, group_id: PlanningGroupID, target: TransformControlsHandle + ) -> None: if self._closed: return - if self._suppress_target_callbacks or self.state.selected_robot is None: + if self._suppress_target_callbacks or group_id not in self.state.selected_group_ids: return pose = self._pose_from_transform_target(target) if pose is None: return self.state.cartesian_target = pose + self.state.pose_targets[group_id] = pose sequence_id = self.state.next_sequence_id() self._worker.submit( TargetEvaluationRequest( sequence_id=sequence_id, source="cartesian", - robot_name=self.state.selected_robot, - pose=pose, + group_ids=self.state.selected_group_ids, + auxiliary_group_ids=self.state.auxiliary_group_ids, + pose_targets=dict(self.state.pose_targets), ) ) self.refresh() def _submit_joint_target_evaluation(self) -> None: - robot_name = self.state.selected_robot - if robot_name is None: + targets = self._target_set_from_sliders() + if targets is None: return - target = self._target_from_sliders(robot_name) - if target is None: - return - self.state.joint_target = list(target.position) - self._move_joint_target_visuals(robot_name, target) + self.state.group_joint_targets = targets + self._refresh_target_joints_from_groups() + self._move_joint_target_visuals() sequence_id = self.state.next_sequence_id() self._worker.submit( TargetEvaluationRequest( sequence_id=sequence_id, source="joints", - robot_name=robot_name, - joints=target, + group_ids=self.state.selected_group_ids, + joint_targets=dict(targets), ) ) self.refresh() - def _move_joint_target_visuals(self, robot_name: str, target: JointState) -> None: + def _move_joint_target_visuals(self) -> None: """Optimistically move target visuals before collision/feasibility returns.""" - config = self.adapter.get_robot_config(robot_name) - robot_id = self.adapter.robot_id_for_name(robot_name) - if self.scene is not None and config is not None and robot_id is not None: - self.scene.set_target_joints(str(robot_id), config.joint_names, list(target.position)) - pose = self.adapter.get_ee_pose(robot_name, target) - if pose is not None: - self._suppress_target_callbacks = True - try: - self.scene.set_target_pose(str(robot_id), pose) - finally: - self._suppress_target_callbacks = False + if self.scene is None: + return + groups = self._group_info_by_id() + for group_id, target in self.state.group_joint_targets.items(): + group = groups.get(group_id) + if group is None: + continue + robot_name = str(group["robot_name"]) + robot_id = self.adapter.robot_id_for_name(robot_name) + config = self.adapter.get_robot_config(robot_name) + if robot_id is None or config is None: + continue + local_positions = dict(zip(target.name, target.position, strict=False)) + joints = [ + float(local_positions.get(str(global_name), 0.0)) + for global_name in group["joint_names"] + ] + self.scene.set_target_joints(str(robot_id), group["local_joint_names"], joints) def _handle_target_evaluation_request( self, request: TargetEvaluationRequest - ) -> TargetEvaluation: + ) -> TargetEvaluation | TargetSetEvaluation: if request.source == "cartesian": - if request.pose is None: + if not request.pose_targets: return {"success": False, "status": "INVALID", "message": "No pose target"} - return self.adapter.evaluate_pose_target(request.pose, request.robot_name) - if request.joints is None: + return self.adapter.evaluate_pose_target_set( + request.pose_targets, + auxiliary_groups=request.auxiliary_group_ids, + seed=self.state.last_valid_target_joints, + ) + if not request.joint_targets: return {"success": False, "status": "INVALID", "message": "No joint target"} - return self.adapter.evaluate_joint_target(request.joints, request.robot_name) + return self.adapter.evaluate_joint_target_set(request.joint_targets) def _apply_target_evaluation_result( - self, request: TargetEvaluationRequest, result: TargetEvaluation + self, request: TargetEvaluationRequest, result: TargetEvaluation | TargetSetEvaluation ) -> None: if self._closed: return @@ -470,33 +726,35 @@ def _apply_target_evaluation_result( TargetStatus.FEASIBLE if success and collision_free else TargetStatus.INFEASIBLE ) self.state.error = "" if success and collision_free else self.state.feasibility.message - if request.source == "joints": - joint_state = result.get("joint_state") - if isinstance(joint_state, JointState): - self.state.joint_target = list(joint_state.position) - if request.source == "cartesian": - joint_state = result.get("joint_state") - if isinstance(joint_state, JointState): - self.state.joint_target = list(joint_state.position) - pose = result.get("ee_pose") - if isinstance(pose, Pose): - self.state.cartesian_target = pose + target_joints = result.get("target_joints") or result.get("joint_state") + if isinstance(target_joints, JointState) and success and collision_free: + self.state.target_joints = JointState(target_joints) + self.state.last_valid_target_joints = JointState(target_joints) + self._split_target_joints_by_group(target_joints) + group_poses = result.get("group_poses", {}) + if isinstance(group_poses, dict): + self.state.group_poses = { + str(group_id): pose + for group_id, pose in group_poses.items() + if isinstance(pose, Pose) + } + group_diagnostics = result.get("group_diagnostics", {}) + if isinstance(group_diagnostics, dict): + self.state.group_diagnostics = { + str(group_id): str(message) for group_id, message in group_diagnostics.items() + } + if request.source == "cartesian" and success and collision_free: self._sync_controls_from_targets() self._update_target_visual_state() self.refresh() def _sync_controls_from_targets(self) -> None: - robot_name = self.state.selected_robot - if robot_name is None: - return - config = self.adapter.get_robot_config(robot_name) - if config is not None and self.state.joint_target is not None: - self._set_slider_values(list(config.joint_names), list(self.state.joint_target)) - robot_id = self.adapter.robot_id_for_name(robot_name) - if self.scene is not None and robot_id is not None: - self.scene.set_target_joints( - str(robot_id), config.joint_names, self.state.joint_target - ) + if self.state.target_joints is not None: + positions_by_name = dict( + zip(self.state.target_joints.name, self.state.target_joints.position, strict=False) + ) + self._set_slider_values(list(positions_by_name), list(positions_by_name.values())) + self._move_joint_target_visuals() # Do not write the Cartesian target back into the active transform # control here. The gizmo is the source of truth for Cartesian edits; # programmatic pose writes from delayed IK results can fight fast user @@ -540,20 +798,20 @@ def _update_control_state(self) -> None: self._update_target_visual_state() def _update_target_visual_state(self) -> None: - if self.scene is None or self.state.selected_robot is None: - return - robot_id = self.adapter.robot_id_for_name(self.state.selected_robot) - if robot_id is None: + if self.scene is None: return - self.scene.set_target_visual_state( - str(robot_id), self.state.feasibility.status == FeasibilityStatus.FEASIBLE - ) + for group_id in self.state.selected_group_ids: + self.scene.set_target_visual_state( + group_id, self.state.feasibility.status == FeasibilityStatus.FEASIBLE + ) def _submit_plan(self) -> None: if self._closed: return - robot_name = self.state.selected_robot - if robot_name is None: + if not self.state.selected_group_ids: + self._set_recoverable_error( + "Cannot plan until target is feasible and manipulation is idle" + ) return if not self.state.can_plan(): self._set_recoverable_error( @@ -571,22 +829,25 @@ def operation() -> None: self.state.plan_state.status = PlanStatus.FAILED self._finish_operation("reset=False", clear_error=False, operation_id=operation_id) return - target = self._target_from_sliders(robot_name) - if target is None: + targets = self._target_set_from_sliders() + if targets is None: self.state.plan_state.status = PlanStatus.FAILED self._finish_operation( "plan_to_joints=False", clear_error=False, operation_id=operation_id ) return - ok = self.adapter.plan_to_joints(target, robot_name) + ok = self.adapter.plan_target_set(targets) if not self._operation_is_current(operation_id): return if ok: self.state.plan_state.status = PlanStatus.FRESH - self.state.plan_state.robot = robot_name - self.state.plan_state.target_joints = list(target.position) + self.state.plan_state.group_ids = self.state.selected_group_ids + self.state.plan_state.robot = self.state.selected_robot + self.state.plan_state.target_joints = list( + self.state.target_joints.position if self.state.target_joints else [] + ) self.state.plan_state.target_pose = self.state.cartesian_target - self.state.plan_state.start_joints_snapshot = list(self.state.current_joints or []) + self.state.plan_state.start_joints_snapshot = self._current_snapshot_by_group() self.state.plan_state.planned_path = None else: self.state.plan_state.status = PlanStatus.FAILED @@ -599,9 +860,6 @@ def operation() -> None: def _submit_preview(self) -> None: if self._closed: return - robot_name = self.state.selected_robot - if robot_name is None: - return if not self.state.can_preview(): self._set_recoverable_error("No fresh plan to preview") return @@ -611,7 +869,7 @@ def operation() -> None: if not self._operation_is_current(operation_id): return self.state.action_status = ActionStatus.PREVIEWING - ok = self.adapter.preview_plan(robot_name) + ok = self.adapter.preview_target_set_plan() self._finish_operation(f"preview={ok}", operation_id=operation_id) self._operation_worker.submit( @@ -623,9 +881,6 @@ def operation() -> None: def _submit_execute(self) -> None: if self._closed: return - robot_name = self.state.selected_robot - if robot_name is None: - return if not self.config.allow_plan_execute: self._set_recoverable_error( "Panel execution disabled; set allow_plan_execute=True to enable" @@ -643,7 +898,7 @@ def operation() -> None: return self.state.action_status = ActionStatus.EXECUTING self.state.plan_state.status = PlanStatus.EXECUTING - ok = self.adapter.execute(robot_name) + ok = self.adapter.execute_target_set_plan() if not self._operation_is_current(operation_id): return if not ok: @@ -762,7 +1017,10 @@ def _pose_from_transform_target(self, target: TransformControlsHandle) -> Pose | return Pose({"position": [px, py, pz], "orientation": [qx, qy, qz, qw]}) def _feasibility_status( - self, result: TargetEvaluation, success: bool, collision_free: bool + self, + result: TargetEvaluation | TargetSetEvaluation, + success: bool, + collision_free: bool, ) -> FeasibilityStatus: status = str(result.get("status", "")).upper() if success and collision_free: diff --git a/dimos/manipulation/visualization/viser/scene.py b/dimos/manipulation/visualization/viser/scene.py index cd50ab098e..251dac6126 100644 --- a/dimos/manipulation/visualization/viser/scene.py +++ b/dimos/manipulation/visualization/viser/scene.py @@ -155,6 +155,9 @@ def dispatch(event: TransformControlsEvent) -> None: self._handles[handle_key] = handle return handle + def remove_target_controls(self, robot_id: str) -> None: + self._remove_handle(f"{robot_id}:ee_control") + def update_current_robot(self, robot_id: str, joint_state: JointState | None) -> None: config = self._configs_by_id.get(robot_id) if config is None or joint_state is None: diff --git a/dimos/manipulation/visualization/viser/state.py b/dimos/manipulation/visualization/viser/state.py index b46097df95..bd28962b2d 100644 --- a/dimos/manipulation/visualization/viser/state.py +++ b/dimos/manipulation/visualization/viser/state.py @@ -21,7 +21,8 @@ import threading from typing import Literal -from dimos.manipulation.visualization.types import RobotInfo, TargetEvaluation +from dimos.manipulation.planning.spec.models import PlanningGroupID +from dimos.manipulation.visualization.types import RobotInfo, TargetEvaluation, TargetSetEvaluation from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.sensor_msgs.JointState import JointState from dimos.utils.logging_config import setup_logger @@ -92,16 +93,25 @@ class FeasibilityState: @dataclass class PanelPlanState: status: PlanStatus = PlanStatus.NONE + group_ids: tuple[PlanningGroupID, ...] = () robot: str | None = None target_pose: Pose | None = None target_joints: list[float] | None = None - start_joints_snapshot: list[float] | None = None + start_joints_snapshot: dict[PlanningGroupID, list[float]] = field(default_factory=dict) planned_path: list[JointState] | None = None @dataclass class PanelState: selected_robot: str | None = None + selected_group_ids: tuple[PlanningGroupID, ...] = () + auxiliary_group_ids: tuple[PlanningGroupID, ...] = () + pose_targets: dict[PlanningGroupID, Pose] = field(default_factory=dict) + group_joint_targets: dict[PlanningGroupID, JointState] = field(default_factory=dict) + target_joints: JointState | None = None + last_valid_target_joints: JointState | None = None + group_poses: dict[PlanningGroupID, Pose] = field(default_factory=dict) + group_diagnostics: dict[PlanningGroupID, str] = field(default_factory=dict) runtime: PanelRuntime = PanelRuntime.STOPPED backend_status: BackendConnectionStatus = BackendConnectionStatus.DISCONNECTED target_status: TargetStatus = TargetStatus.EMPTY @@ -133,9 +143,10 @@ def can_plan(self) -> bool: return ( self.runtime == PanelRuntime.RUNNING and self.backend_status == BackendConnectionStatus.READY - and self.selected_robot is not None + and bool(self.selected_group_ids) and self.action_status == ActionStatus.IDLE and self.target_status == TargetStatus.FEASIBLE + and self.target_joints is not None and self.manipulation_state in {"IDLE", "COMPLETED", "FAULT"} and self.plan_state.status != PlanStatus.PLANNING ) @@ -169,19 +180,24 @@ def can_execute( and self.target_status == TargetStatus.FEASIBLE and self.manipulation_state in {"IDLE", "COMPLETED"} and plan.status == PlanStatus.FRESH - and plan.robot == self.selected_robot - and plan.start_joints_snapshot is not None - and self.current_joints is not None + and plan.group_ids == self.selected_group_ids + and bool(plan.start_joints_snapshot) + and self.target_joints is not None ): return False - if len(plan.start_joints_snapshot) != len(self.current_joints): + if self.current_joints is None: return False - return all( - abs(expected - current) <= current_tolerance - for expected, current in zip( - plan.start_joints_snapshot, self.current_joints, strict=False + # Multi-group freshness is checked by group snapshot when available. The + # robot-level current-joint fallback preserves one-group legacy tests. + if len(plan.start_joints_snapshot) == 1: + snapshot = next(iter(plan.start_joints_snapshot.values())) + if len(snapshot) != len(self.current_joints): + return False + return all( + abs(expected - current) <= current_tolerance + for expected, current in zip(snapshot, self.current_joints, strict=False) ) - ) + return True @property def connected(self) -> bool: @@ -203,9 +219,13 @@ def module_state(self) -> str: class TargetEvaluationRequest: sequence_id: int source: PreviewSource - robot_name: str + group_ids: tuple[PlanningGroupID, ...] + robot_name: str | None = None + auxiliary_group_ids: tuple[PlanningGroupID, ...] = () pose: Pose | None = None + pose_targets: dict[PlanningGroupID, Pose] = field(default_factory=dict) joints: JointState | None = None + joint_targets: dict[PlanningGroupID, JointState] = field(default_factory=dict) class TargetEvaluationWorker: @@ -218,8 +238,10 @@ class TargetEvaluationWorker: def __init__( self, - handler: Callable[[TargetEvaluationRequest], TargetEvaluation], - apply_result: Callable[[TargetEvaluationRequest, TargetEvaluation], None], + handler: Callable[[TargetEvaluationRequest], TargetEvaluation | TargetSetEvaluation], + apply_result: Callable[ + [TargetEvaluationRequest, TargetEvaluation | TargetSetEvaluation], None + ], ) -> None: self._handler = handler self._apply_result = apply_result diff --git a/dimos/manipulation/visualization/viser/test_operation_worker.py b/dimos/manipulation/visualization/viser/test_operation_worker.py index ee1d5c29c0..71a7857188 100644 --- a/dimos/manipulation/visualization/viser/test_operation_worker.py +++ b/dimos/manipulation/visualization/viser/test_operation_worker.py @@ -141,6 +141,12 @@ def cancel(self) -> bool: def plan_to_joints(self, joints: JointState, robot_name: str | None = None) -> bool: return True + def plan_target_set(self, joint_targets: dict[str, JointState]) -> bool: + return True + + def preview_target_set_plan(self) -> bool: + return True + def test_operation_worker_uses_per_operation_timeout() -> None: errors: list[str] = [] @@ -205,7 +211,8 @@ def test_gui_only_preview_submits_timeout_override(monkeypatch: pytest.MonkeyPat monkeypatch.setattr(gui, "_operation_worker", FakeTimeoutSubmitWorker(submissions)) gui.state.runtime = PanelRuntime.RUNNING gui.state.backend_status = BackendConnectionStatus.READY - gui.state.selected_robot = "arm" + gui.state.selected_group_ids = ("arm:manipulator",) + gui.state.target_joints = JointState({"name": ["arm/j1"], "position": [1.0]}) gui.state.target_status = TargetStatus.FEASIBLE gui.state.manipulation_state = "IDLE" diff --git a/dimos/manipulation/visualization/viser/test_state.py b/dimos/manipulation/visualization/viser/test_state.py index 412c227050..040ae6f56e 100644 --- a/dimos/manipulation/visualization/viser/test_state.py +++ b/dimos/manipulation/visualization/viser/test_state.py @@ -20,11 +20,14 @@ PanelState, TargetStatus, ) +from dimos.msgs.sensor_msgs.JointState import JointState def test_panel_can_plan_from_fault_after_planning_failure() -> None: state = PanelState( selected_robot="arm", + selected_group_ids=("arm:manipulator",), + target_joints=JointState({"name": ["arm/j1"], "position": [1.0]}), runtime=PanelRuntime.RUNNING, backend_status=BackendConnectionStatus.READY, target_status=TargetStatus.FEASIBLE, diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index 687065885d..cedd3954b3 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -25,7 +25,7 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") -from dimos.manipulation.visualization.types import RobotInfo, TargetEvaluation +from dimos.manipulation.visualization.types import RobotInfo, TargetEvaluation, TargetSetEvaluation from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.animation import ( PreviewAnimator, @@ -52,6 +52,7 @@ GuiCallback = Callable[[SimpleNamespace], None] ThemeValue = str | bool | tuple[int, int, int] | dict[str, str | dict[str, str]] | None RobotConfigOverride = str | list[str] | list[float] | None +DEFAULT_GROUP_ID = "arm:manipulator" @dataclass @@ -346,6 +347,23 @@ def make_robot_config(**overrides: RobotConfigOverride) -> RobotConfigStub: return config +def make_planning_group_info( + robot_name: str, config: RobotConfigStub | SimpleNamespace +) -> dict[str, object]: + joint_names = [str(name) for name in config.joint_names] + return { + "id": f"{robot_name}:manipulator", + "name": "manipulator", + "robot_name": robot_name, + "joint_names": [f"{robot_name}/{name}" for name in joint_names], + "local_joint_names": joint_names, + "base_link": str(config.base_link), + "tip_link": str(config.end_effector_link), + "has_pose_target": True, + "source": "fallback", + } + + class FakeManipulationModule(SimpleNamespace): """Public ManipulationModule surface used by the in-process Viser adapter tests.""" @@ -382,6 +400,7 @@ def get_robot_info(self, robot_name: str) -> RobotInfo | None: "name": config.name, "world_robot_id": self.robot_id_for_name(robot_name) or robot_name, "joint_names": list(config.joint_names), + "planning_groups": [make_planning_group_info(robot_name, config)], "end_effector_link": config.end_effector_link, "base_link": config.base_link, "max_velocity": 1.0, @@ -429,6 +448,37 @@ def evaluate_pose_target(self, _pose: Pose, _robot_name: str) -> TargetEvaluatio "collision_free": False, } + def evaluate_joint_target_set( + self, joint_targets: dict[str, JointState] + ) -> TargetSetEvaluation: + if not joint_targets: + return {"success": False, "status": "INVALID", "target_joints": None} + names: list[str] = [] + positions: list[float] = [] + collision_free = True + group_poses = {} + world_monitor = getattr(self, "_world_monitor", None) + for group_id, target in joint_targets.items(): + robot_name = group_id.split(":", 1)[0] + robot_id = self.robot_id_for_name(robot_name) + names.extend(target.name) + positions.extend(float(value) for value in target.position) + if world_monitor is not None and robot_id is not None: + collision_free = collision_free and world_monitor.is_state_valid(robot_id, target) + group_poses[group_id] = world_monitor.get_ee_pose(robot_id, target) + return { + "success": True, + "status": "FEASIBLE" if collision_free else "COLLISION", + "message": "Target is collision-free" if collision_free else "Target is in collision", + "collision_free": collision_free, + "target_joints": JointState({"name": names, "position": positions}), + "group_ids": tuple(joint_targets), + "group_poses": group_poses, + } + + def plan_to_joint_targets(self, _joint_targets: dict[str, JointState]) -> bool: + return True + def make_adapter_with_robot() -> InProcessViserAdapter: current = FakeJointState(["j1", "j2"], position=[0.3, 0.4]) @@ -559,8 +609,8 @@ def test_gui_ignores_target_evaluation_after_close( request = TargetEvaluationRequest( sequence_id=sequence_id, source="joints", - robot_name="arm", - joints=FakeJointState(["j1", "j2"], position=[0.1, 0.2]), + group_ids=(DEFAULT_GROUP_ID,), + joint_targets={DEFAULT_GROUP_ID: FakeJointState(["arm/j1", "arm/j2"], position=[0.1, 0.2])}, ) gui.close() @@ -1106,12 +1156,40 @@ def test_gui_initializes_pose_selector_to_current_ee_pose( FakeTransformServer(), lambda *args, **kwargs: FakeViserUrdfWithMeshes(), preview_fps=10.0 ) gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) - control = scene._handles["robot-1:ee_control"] + control = scene._handles[f"{DEFAULT_GROUP_ID}:ee_control"] assert control.position == (0.1, 0.2, 0.3) assert control.wxyz == (0.9, 0.1, 0.2, 0.3) assert gui.state.cartesian_target is current_pose +def test_gui_removes_pose_selector_when_group_is_deselected( + make_panel: Callable[..., ViserPanelGui], +) -> None: + current = FakeJointState(["j1"], position=[0.25]) + current_pose = SimpleNamespace( + position=SimpleNamespace(x=0.1, y=0.2, z=0.3), + orientation=SimpleNamespace(w=1.0, x=0.0, y=0.0, z=0.0), + ) + config = make_robot_config(joint_names=["j1"], home_joints=[0.0]) + module = FakeManipulationModule(_robots={"arm": ("robot-1", config, None)}) + world_monitor = SimpleNamespace( + get_current_joint_state=lambda robot_id: current, + is_state_stale=lambda robot_id, max_age=1.0: False, + get_ee_pose=lambda robot_id, joint_state=None: current_pose, + ) + adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + scene = ViserManipulationScene( + FakeTransformServer(), lambda *args, **kwargs: FakeViserUrdfWithMeshes(), preview_fps=10.0 + ) + gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) + control = scene._handles[f"{DEFAULT_GROUP_ID}:ee_control"] + + gui._set_group_selected(DEFAULT_GROUP_ID, False) + + assert f"{DEFAULT_GROUP_ID}:ee_control" not in scene._handles + assert control.removed is True + + def test_gui_preset_dropdown_and_controls_include_init_home_current_and_callbacks( make_panel: Callable[..., ViserPanelGui], ) -> None: @@ -1130,11 +1208,11 @@ def test_gui_preset_dropdown_and_controls_include_init_home_current_and_callback adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) gui = make_panel(FakeGuiServer(), adapter) assert gui._handles["preset"].options == ["Select preset...", "Init", "Current", "Home"] - assert list(gui._joint_sliders) == ["j1", "j2"] + assert list(gui._joint_sliders) == ["arm/j1", "arm/j2"] gui._apply_preset("Home") - assert [gui._joint_sliders[name].value for name in ("j1", "j2")] == [1.0, 2.0] + assert [gui._joint_sliders[name].value for name in ("arm/j1", "arm/j2")] == [1.0, 2.0] gui._apply_preset("Current") - assert [gui._joint_sliders[name].value for name in ("j1", "j2")] == [0.25, 0.5] + assert [gui._joint_sliders[name].value for name in ("arm/j1", "arm/j2")] == [0.25, 0.5] gui._submit_execute() assert gui.state.error == "Panel execution disabled; set allow_plan_execute=True to enable" @@ -1158,10 +1236,12 @@ def test_gui_rebuilding_joint_sliders_removes_stale_viser_handles( assert [slider.value for slider in stale_sliders] == [0.0, 0.0] current.position = [-0.738, -0.2826151825863572] + gui.state.target_joints = None + gui.state.group_joint_targets.clear() gui._build_joint_sliders() assert all(slider.removed is True for slider in stale_sliders) - assert [gui._joint_sliders[name].value for name in ("j1", "j2")] == [ + assert [gui._joint_sliders[name].value for name in ("arm/j1", "arm/j2")] == [ -0.738, -0.2826151825863572, ] @@ -1204,9 +1284,9 @@ def test_panel_execution_is_gated_by_default_and_refresh_updates_robot_controls( gui = make_panel(FakeGuiServer(), adapter) gui.refresh() assert gui.state.selected_robot == "arm" - assert list(gui._joint_sliders) == ["j1"] + assert list(gui._joint_sliders) == ["arm/j1"] gui._apply_preset("Home") - assert gui._joint_sliders["j1"].value == 0.5 + assert gui._joint_sliders["arm/j1"].value == 0.5 gui._submit_execute() assert "Panel execution disabled" in gui.state.error @@ -1241,38 +1321,44 @@ def test_gui_moves_joint_target_immediately_and_stores_evaluated_joint_solution( gui._worker = SimpleNamespace( submit=lambda request: requests.append(request), stop=lambda: None ) - gui._joint_sliders["j1"].value = 0.25 - gui._joint_sliders["j2"].value = 0.75 + gui._joint_sliders["arm/j1"].value = 0.25 + gui._joint_sliders["arm/j2"].value = 0.75 gui._submit_joint_target_evaluation() assert target_updates[-1] == ("robot-1", ["j1", "j2"], [0.25, 0.75]) - assert target_pose_updates[-1] == ("robot-1", target_pose) + assert target_pose_updates[-1] == (DEFAULT_GROUP_ID, target_pose) assert requests[-1].source == "joints" - stale_request = TargetEvaluationRequest(sequence_id=1, source="joints", robot_name="arm") - fresh_request = TargetEvaluationRequest(sequence_id=2, source="joints", robot_name="arm") + stale_request = TargetEvaluationRequest( + sequence_id=1, source="joints", group_ids=(DEFAULT_GROUP_ID,) + ) + fresh_request = TargetEvaluationRequest( + sequence_id=2, source="joints", group_ids=(DEFAULT_GROUP_ID,) + ) gui.state.latest_sequence_id = 2 gui._apply_target_evaluation_result( stale_request, { "success": True, "collision_free": True, - "joint_state": adapter.joints_from_values(["j1", "j2"], [9.0, 9.0]), + "target_joints": adapter.joints_from_values(["arm/j1", "arm/j2"], [9.0, 9.0]), }, ) - assert gui.state.joint_target == [0.25, 0.75] + assert gui.state.target_joints is not None + assert list(gui.state.target_joints.position) == [0.25, 0.75] gui._apply_target_evaluation_result( fresh_request, { "success": True, "collision_free": True, - "joint_state": adapter.joints_from_values(["j1", "j2"], [1.0, 2.0]), + "target_joints": adapter.joints_from_values(["arm/j1", "arm/j2"], [1.0, 2.0]), }, ) assert gui.state.target_status == TargetStatus.FEASIBLE assert gui.state.feasibility.status == FeasibilityStatus.FEASIBLE - assert gui.state.joint_target == [1.0, 2.0] - assert [gui._joint_sliders[name].value for name in ("j1", "j2")] == [0.25, 0.75] + assert gui.state.target_joints is not None + assert list(gui.state.target_joints.position) == [1.0, 2.0] + assert [gui._joint_sliders[name].value for name in ("arm/j1", "arm/j2")] == [0.25, 0.75] assert target_updates[-1] == ("robot-1", ["j1", "j2"], [0.25, 0.75]) @@ -1302,7 +1388,9 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( gui.state.cartesian_target = Pose( {"position": [0.1, 0.2, 0.3], "orientation": [0.0, 0.0, 0.0, 1.0]} ) - request = TargetEvaluationRequest(sequence_id=1, source="cartesian", robot_name="arm") + request = TargetEvaluationRequest( + sequence_id=1, source="cartesian", group_ids=(DEFAULT_GROUP_ID,) + ) gui.state.latest_sequence_id = 1 gui._apply_target_evaluation_result( @@ -1310,12 +1398,12 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( { "success": True, "collision_free": True, - "joint_state": adapter.joints_from_values(["j1", "j2"], [1.0, 2.0]), + "target_joints": adapter.joints_from_values(["arm/j1", "arm/j2"], [1.0, 2.0]), }, ) assert gui.state.target_status == TargetStatus.FEASIBLE - assert [gui._joint_sliders[name].value for name in ("j1", "j2")] == [1.0, 2.0] + assert [gui._joint_sliders[name].value for name in ("arm/j1", "arm/j2")] == [1.0, 2.0] assert target_joint_updates[-1] == ("robot-1", ["j1", "j2"], [1.0, 2.0]) assert target_pose_updates == [] @@ -1345,7 +1433,7 @@ def test_gui_collision_evaluation_marks_target_infeasible_and_colors_scene( set_target_visual_state=lambda *args: visual_states.append(args), ) gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) - request = TargetEvaluationRequest(sequence_id=1, source="joints", robot_name="arm") + request = TargetEvaluationRequest(sequence_id=1, source="joints", group_ids=(DEFAULT_GROUP_ID,)) gui.state.latest_sequence_id = 1 result = adapter.evaluate_joint_target(FakeJointState(["j1"], position=[1.0]), "arm") @@ -1355,7 +1443,7 @@ def test_gui_collision_evaluation_marks_target_infeasible_and_colors_scene( assert gui.state.target_status == TargetStatus.INFEASIBLE assert gui.state.feasibility.status == FeasibilityStatus.COLLISION assert gui.state.error == "Target is in collision" - assert visual_states[-1] == ("robot-1", False) + assert visual_states[-1] == (DEFAULT_GROUP_ID, False) def test_gui_safe_execute_requires_fresh_matching_plan_and_clear_resets_path( @@ -1400,8 +1488,8 @@ def test_gui_safe_execute_requires_fresh_matching_plan_and_clear_resets_path( gui.state.target_status = TargetStatus.FEASIBLE gui.state.plan_state = PanelPlanState( status=PlanStatus.FRESH, - robot="arm", - start_joints_snapshot=[1.2], + group_ids=(DEFAULT_GROUP_ID,), + start_joints_snapshot={DEFAULT_GROUP_ID: [1.2]}, planned_path=planned, ) gui._submit_execute() @@ -1410,9 +1498,9 @@ def test_gui_safe_execute_requires_fresh_matching_plan_and_clear_resets_path( gui.state.action_status = ActionStatus.IDLE gui.state.error = "" - gui.state.plan_state.start_joints_snapshot = [1.0] + gui.state.plan_state.start_joints_snapshot = {DEFAULT_GROUP_ID: [1.0]} gui._submit_execute() - assert executed == ["arm"] + assert executed == [None] gui._submit_clear() assert cleared == [True] @@ -1433,7 +1521,8 @@ def test_gui_plan_target_failure_recovers_action_state( submit=lambda operation, **_kwargs: operation(), stop=lambda timeout=2.0: None ), ) - gui.state.selected_robot = "missing" + gui.state.selected_group_ids = ("missing",) + gui.state.target_joints = JointState({"name": ["missing/j1"], "position": [1.0]}) gui.state.target_status = TargetStatus.FEASIBLE gui.state.manipulation_state = "IDLE" @@ -1441,7 +1530,7 @@ def test_gui_plan_target_failure_recovers_action_state( assert gui.state.action_status == ActionStatus.IDLE assert gui.state.plan_state.status == PlanStatus.FAILED - assert gui.state.error == "No robot config" + assert gui.state.error == "Unknown planning group: missing" assert gui.state.last_result == "plan_to_joints=False" @@ -1465,12 +1554,12 @@ def reset() -> bool: calls.append("reset") return True - def plan_to_joints(_joints: JointState, _robot_name: str | None = None) -> bool: + def plan_target_set(_joint_targets: dict[str, JointState]) -> bool: calls.append("plan") return True monkeypatch.setattr(adapter, "reset", reset) - monkeypatch.setattr(adapter, "plan_to_joints", plan_to_joints) + monkeypatch.setattr(adapter, "plan_target_set", plan_target_set) gui.state.target_status = TargetStatus.FEASIBLE gui.state.manipulation_state = "FAULT" @@ -1529,8 +1618,12 @@ def operation() -> None: def test_target_evaluation_worker_coalesces_pending_requests() -> None: worker = TargetEvaluationWorker(lambda request: {}, lambda request, result: None) - old_request = TargetEvaluationRequest(sequence_id=1, source="joints", robot_name="arm") - new_request = TargetEvaluationRequest(sequence_id=2, source="joints", robot_name="arm") + old_request = TargetEvaluationRequest( + sequence_id=1, source="joints", group_ids=(DEFAULT_GROUP_ID,) + ) + new_request = TargetEvaluationRequest( + sequence_id=2, source="joints", group_ids=(DEFAULT_GROUP_ID,) + ) worker.submit(old_request) worker.submit(new_request) diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index 0a05e6982d..bcbd550d0a 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -129,12 +129,57 @@ touching `WorldSpec`, IK, planner objects, or live Drake contexts directly. Rend mutable joint state/path containers at the read boundary, then updates the Viser scene after manipulation/world accessors have returned. +#### Viser Planning Target Set workflow + +The Viser manipulation panel is planning-group centric. Select one or more planning groups +to form a **Planning Target Set**; IK, feasibility checks, planning, preview, execute, +clear-plan, and plan freshness are scoped to that whole set. A single xArm uses the same +workflow as a one-group target set, while dual-arm stacks can select both manipulators and +plan them together. + +- Use the planning-group checklist to add or remove groups. **Select all manipulators** + selects every planning group named `manipulator`. +- Pose target gizmos are keyed by planning group ID. Moving any selected pose gizmo triggers + whole-set IK evaluation and updates the global target joints when IK succeeds. +- Joint sliders are grouped by planning group. Editing joints triggers whole-set joint + evaluation and refreshes visible pose gizmos from FK outputs when available. +- Auxiliary groups are selected target-set members without direct gizmos. Their joints still + participate in IK seeds, target joints, feasibility, planning, preview, and execute. +- The panel exposes one Plan, Preview, Execute, Cancel, and Clear row for the whole target set; + normal operation does not expose per-robot preview or execute controls. + +Single xArm Viser example: + +```bash +uv run dimos run xarm7-planner-coordinator \ + -o manipulationmodule.visualization.backend=viser +``` + +Enable browser-panel execution only when an operator is intentionally allowed to execute plans: + +```bash +uv run dimos run xarm7-planner-coordinator \ + -o manipulationmodule.visualization.backend=viser \ + -o manipulationmodule.visualization.allow_plan_execute=true +``` + +Dual-arm mock Viser example: + +```bash +uv run dimos run dual-xarm6-planner \ + -o manipulationmodule.visualization.backend=viser +``` + External manipulation visualizers are initialized from a backend-neutral planning-scene snapshot after the planning world has added its robots. This snapshot maps world robot IDs to `RobotModelConfig` metadata so Viser can prepare current, target, and transient preview robot visuals without `WorldMonitor` depending on Viser-specific hooks. Embedded Meshcat visualization does not need extra setup because it observes the Drake world directly. +Viser renders robot placement as authored in the prepared URDF/xacro output. It does not apply +`RobotModelConfig.base_pose` as an additional implicit visual transform, which avoids +double-applying placement for multi-robot models that already encode offsets in URDF/xacro. + Panel execution is opt-in. Leave `allow_plan_execute=False` unless the operator intentionally wants the browser panel to call the existing manipulation execution path. diff --git a/openspec/changes/add-viser-planning-target-set/.openspec.yaml b/openspec/changes/add-viser-planning-target-set/.openspec.yaml new file mode 100644 index 0000000000..d57ce332e0 --- /dev/null +++ b/openspec/changes/add-viser-planning-target-set/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-20 diff --git a/openspec/changes/add-viser-planning-target-set/design.md b/openspec/changes/add-viser-planning-target-set/design.md new file mode 100644 index 0000000000..a1d98a0712 --- /dev/null +++ b/openspec/changes/add-viser-planning-target-set/design.md @@ -0,0 +1,139 @@ +## Context + +DimOS manipulation now treats planning groups as first-class planning units and stores successful results as `GeneratedPlan` artifacts over global joint names. Viser currently lags behind that model: the panel selects one robot, maintains one target, and delegates through robot-scoped convenience calls. That works for the single-arm case, but it does not express a coordinated dual-arm intent where `left_arm/manipulator` and `right_arm/manipulator` should be solved, checked, planned, previewed, and executed as one whole target set. + +The current Viser implementation already has useful pieces: in-process adapter calls, target transform controls, joint sliders, preview animation, and `VisualizationSpec` integration through `WorldMonitor`. This change should preserve those pieces while changing their semantic unit from selected robot to planning target set. + +Root `CONTEXT.md` defines the canonical language: Planning Group, Planning Group Selection, Auxiliary Planning Group, Planning Target Set, Generated Plan, Global Joint Name, and Robot Placement. This design follows those terms. For Viser placement, this change deliberately relies on URDF/xacro-authored placement and does not apply `RobotModelConfig.base_pose` inside Viser. + +## Goals / Non-Goals + +**Goals:** + +- Make the Viser panel group-centric instead of robot-centric. +- Let users establish a Planning Target Set by selecting one or more planning groups. +- Show target gizmos keyed by Planning Group ID for selected pose-targetable groups. +- Treat auxiliary planning groups as normal target-set members without assigned gizmos. +- Make joint panels, IK, feasibility, planning, preview, execute, and freshness whole-set scoped. +- Normalize pose-authored targets into global joint targets via realtime whole-set IK. +- Add multi-target Pink IK behavior for same-robot multiple frame targets and cross-robot grouped solves. +- Keep planning, preview, and execution based on the whole `GeneratedPlan` for the target set. +- Preserve the single-robot workflow as the one-group target-set case. + +**Non-Goals:** + +- Do not add a new CLI command. +- Do not make Viser apply `base_pose` or infer backend weld behavior. +- Do not make IK collision-aware; collision validation and path collision avoidance remain WorldSpec/planner responsibilities. +- Do not add atomic multi-controller execution semantics beyond existing generated-plan projection/dispatch. +- Do not expose normal per-group or per-robot Plan/Preview/Execute controls in the target-set workflow. +- Do not require named left/right/dual-arm presets; a checklist plus “select all manipulators” is enough for the first UI. + +## DimOS Architecture + +### Viser panel and scene + +The Viser panel should maintain a Planning Target Set state rather than a single selected robot state. The target set includes selected group IDs, target authoring state, last valid global target joints, whole-set status, diagnostics, plan freshness, and plan status. + +Current robot visuals and preview ghosts remain robot-keyed because rendering a plan projects to whole robot models. Target transform controls become planning-group-keyed because pose targets belong to planning groups. A pose-targetable selected group gets a gizmo such as `/targets/left_arm/manipulator`; an auxiliary selected group participates without a gizmo. + +The panel should display one unified Target Set joint panel, visually grouped by planning group. Per-group sections are views into one target set, not independent state machines. The action row operates on the whole set. + +### ManipulationModule and adapter boundary + +Viser should not implement IK or target-set feasibility semantics directly. The in-process adapter should expose whole-set operations backed by `ManipulationModule`, such as: + +- evaluate pose targets for a planning target set; +- evaluate joint targets for a planning target set; +- plan the current target set through joint-target planning; +- preview and execute the current whole generated plan. + +Evaluation results should use global joint names as the semantic output. Viser may display local labels inside group sections, but the returned target joint state must be unambiguous and directly usable by planning APIs. + +Pose-authored targets should normalize to joint targets before planning. The panel should call pose target set evaluation during gizmo edits, update the last valid target joints, then call joint target planning when the user plans. Joint edits should call joint target set evaluation, update pose gizmos through FK where available, and update whole-set feasibility. + +### Pink IK + +Pink IK should expose one multi-target pose solve behavior while grouping internally by robot model: + +- Same-robot multiple pose targets: solve one Pink configuration with multiple frame tasks. +- Cross-robot pose targets: solve one Pink problem per robot model and combine the results into one global selected joint target. + +Auxiliary groups participate in the selected joints and seed/target vector but do not receive direct frame tasks. They are still solved, checked, planned, previewed, and executed with the whole target set. + +IK should use the last valid target joints as the seed after target-set initialization. When a group is selected, its target initializes from current state. On IK failure, keep the last valid target joints, mark the whole target set invalid/stale, and disable planning. + +### Planning and execution + +Planning remains joint-target based after target-set evaluation. The whole target set lowers to `plan_to_joint_targets(...)` keyed by planning group. Preview and execute operate on the full generated plan without normal robot filtering in the panel. + +Plan freshness is whole-set scoped. When planning succeeds, store a current-joint snapshot for all selected groups. Execution is enabled only if current selected-group joints still match that snapshot within tolerance. + +### Blueprints and streams + +No stream contract changes are expected. Existing xArm planner/coordinator blueprints remain the manual QA target. Viser should work with `xarm7-planner-coordinator` and dual xArm mock planner/coordinator through visualization config overrides. + +### Skills/MCP and generated registries + +No skill/MCP tool contract changes are expected. No blueprint registry regeneration is expected unless implementation changes blueprint definitions. + +## Decisions + +1. **Use Planning Target Set as the UI semantic unit.** + - Rationale: Once groups are selected, independent per-group UI state causes discrepancies between IK, feasibility, planning, and execution. + - Alternative rejected: separate group cards with independent status/actions. + +2. **Keep Viser placement URDF-authored.** + - Rationale: xArm xacro already encodes attachment placement. Applying `base_pose` in Viser risks double placement and duplicates Drake weld heuristics. + - Alternative rejected: backend placement inference in Viser. + +3. **Key target gizmos by Planning Group ID.** + - Rationale: pose targets belong to planning groups, not robots. This avoids a future mismatch for robots with multiple pose-targetable groups. + - Alternative rejected: robot-keyed target controls. + +4. **Plan through joint targets after evaluation.** + - Rationale: The panel can use pose gizmos naturally while the planner receives one uniform global joint target set. + - Alternative rejected: separate UI modes for pose planning and joint planning. + +5. **Group Pink multi-target solving by robot internally.** + - Rationale: Same-robot multi-frame targets need one Pink configuration. Cross-robot targets can be solved per model and combined without building a combined Pinocchio model. + - Alternative deferred: building one combined Pink model across robots. + +6. **Whole-set status is canonical.** + - Rationale: The Plan button must not be enabled because individual group sections look valid while the combined target set is invalid. + - Alternative rejected: per-group canonical statuses. + +## Safety / Simulation / Replay + +Execution remains gated by existing Viser configuration (`allow_plan_execute`) and manipulation module state. The panel should not execute unless the whole plan is fresh and current selected-group joints match the planning start snapshot. + +Collision checking remains outside IK and in WorldSpec/planner behavior. IK may return kinematically valid targets that planning later rejects due to collision or unavailable path. The UI must surface this as whole-set planning failure, not per-group success. + +Manual QA should use mock xArm blueprints first, especially `xarm7-planner-coordinator` and the dual xArm planner/coordinator, before any hardware test. Hardware tests must keep execution explicitly opt-in. + +Replay behavior is not expected to change except that Viser target-set state should consume the same current joint states as the existing panel. + +## Risks / Trade-offs + +- **Realtime IK cost:** Whole-set IK on every gizmo drag can be expensive. Use latest-request-wins and debounce behavior in the existing evaluation worker pattern. +- **State complexity:** The panel state becomes richer. Mitigate by making Planning Target Set the only authoritative UI state and treating per-group displays as projections. +- **Pink multi-frame correctness:** Same-robot multi-frame IK needs careful frame-task construction and result filtering. Add focused tests before relying on the UI. +- **Cross-robot IK limitation:** Cross-robot grouping combines independent kinematic solves. Inter-robot collision is handled by planning, not IK, so some targets may solve IK but fail planning. +- **Docs drift:** `add-planning-groups` artifacts still contain older terms such as ResolvedPlanningGroup. This change should use current glossary language and avoid reviving stale terms. + +## Migration / Rollout + +1. Add target-set evaluation structures and module/adapter methods while preserving single-group behavior. +2. Update Pink IK multi-target behavior with focused tests. +3. Rework Viser panel state and scene target controls to use planning group IDs. +4. Keep existing single-arm Viser interactions working as one selected group. +5. Add dual xArm mock tests and manual QA. +6. Update user-facing documentation for the target-set workflow. + +Rollback can keep the current single-robot panel path if target-set UI is implemented behind a small internal adapter seam, but the final intended state should be group-centric only. + +## Open Questions + +- Exact Python location/name for the target-set evaluation result type. +- Whether target-set evaluation should be public RPC or only in-process adapter/module API initially. +- How much debouncing is needed for realtime whole-set IK with real robots and larger target sets. diff --git a/openspec/changes/add-viser-planning-target-set/docs.md b/openspec/changes/add-viser-planning-target-set/docs.md new file mode 100644 index 0000000000..79a95c95b3 --- /dev/null +++ b/openspec/changes/add-viser-planning-target-set/docs.md @@ -0,0 +1,32 @@ +## User-Facing Docs + +- Update manipulation/Viser usage documentation to describe the Planning Target Set workflow: + - selecting one or more planning groups; + - using group-keyed target gizmos; + - understanding auxiliary groups as selected groups without direct gizmos; + - planning, previewing, and executing the whole target set; + - using the workflow with single xArm and dual xArm mock blueprints. +- If no dedicated Viser usage page exists, add the workflow to the closest manipulation usage or capability document. +- Include an example command for launching xArm planner/coordinator with Viser enabled and execution opt-in clearly marked. + +## Contributor Docs + +- Update manipulation planning contributor notes if they describe robot-scoped Viser behavior or single-target IK assumptions. +- Mention that Viser placement is URDF-authored for this workflow and that `base_pose` is not automatically applied by Viser. + +## Coding-Agent Docs + +- No required `AGENTS.md` update is expected. +- If `docs/coding-agents/` contains manipulation-specific guidance, add a short note that target-set UI state is whole-set scoped and should not reintroduce per-robot Plan/Preview/Execute state. + +## Doc Validation + +- Run link validation for changed docs if available: + - `doclinks` +- If docs contain executable Python snippets, run: + - `md-babel-py run ` +- Run normal formatting/lint checks for any touched docs if configured in the repository. + +## No Docs Needed + +Documentation changes are needed because this change introduces a new user-facing Viser manipulation workflow and changes the mental model from robot selection to planning target sets. diff --git a/openspec/changes/add-viser-planning-target-set/proposal.md b/openspec/changes/add-viser-planning-target-set/proposal.md new file mode 100644 index 0000000000..5379496033 --- /dev/null +++ b/openspec/changes/add-viser-planning-target-set/proposal.md @@ -0,0 +1,53 @@ +## Why + +The current Viser manipulation panel is robot-centric: it selects one robot, edits one target, and plans through robot-scoped compatibility APIs. That is not sufficient for dual-arm and multi-group manipulation, where the user intent is a single coordinated target over a planning group selection such as `left_arm/manipulator` plus `right_arm/manipulator`. + +DimOS now has first-class planning groups and generated plans over global joint names. Viser should expose that model naturally: users select a set of planning groups, edit pose gizmos or joint targets as one target set, and plan/preview/execute one whole generated plan. This also creates a clear path for natural pose-based bimanual planning through multi-target Pink IK. + +## What Changes + +- Add a Viser **Planning Target Set** workflow built on planning group selection rather than selected robot. +- Show planning-group-keyed target gizmos for selected pose-targetable groups. +- Treat auxiliary planning groups as members of the same target set without direct gizmo targets. +- Normalize pose-authored targets through whole-set IK into global joint targets, then plan through joint-target planning. +- Add whole-set evaluation semantics for IK, FK/target pose updates, feasibility, plan freshness, preview, and execute. +- Extend Pink IK to support multi-target pose evaluation through one unified API: + - same-robot targets use one Pink solve with multiple frame tasks; + - cross-robot targets are grouped by robot and combined into one global selected joint target. +- Keep collision validation and collision-free path planning in WorldSpec/planner responsibilities, not in IK semantics. +- Keep Viser robot placement URDF-authored for this change; do not infer or apply `base_pose` in Viser. + +## Affected DimOS Surfaces + +- Modules/streams: + - `ManipulationModule` target evaluation helpers and Viser-facing adapter surface. + - Manipulation planning group registry and group-target evaluation paths. + - Pink IK pose-target solving behavior. + - Viser visualization panel, scene target controls, and runtime state. +- Blueprints/CLI: + - No new CLI command is expected. + - Existing xArm planner/coordinator blueprints should be usable with Viser for single-arm and dual-arm mock validation. +- Skills/MCP: + - No direct MCP skill behavior change is expected. +- Hardware/simulation/replay: + - Dual-arm mock xArm planning should support group-target-set preview and planning in Viser. + - Hardware execution remains gated by existing execution controls and coordinator behavior. +- Docs/generated registries: + - Update manipulation/Viser usage docs if present. + - Update OpenSpec/docs language to reflect Planning Target Set and multi-target Pink IK. + +## Capabilities + +### New Capabilities + +- `viser-planning-target-set`: Viser UI behavior for selecting planning groups, authoring whole-set targets, evaluating target sets, and planning/previewing/executing generated plans. + +### Modified Capabilities + +- `manipulation-planning-groups`: Planning group workflows gain whole-set target authoring/evaluation semantics and multi-target Pink IK support. + +## Impact + +Users get a natural dual-arm Viser workflow: select both arm planning groups, manipulate group-keyed target gizmos, watch the whole target set solve to joint targets, then plan/preview/execute one generated plan. Developers get a cleaner module/UI boundary where Viser owns editing state and `ManipulationModule` owns target-set semantics. + +Compatibility risks are mostly UI/API surface changes around the in-process Viser adapter and target evaluation helpers. Existing single-robot Viser workflows should remain usable as the one-group case of the target-set workflow. Testing should cover single-arm regression, dual xArm mock target-set selection, group-keyed gizmo visibility, whole-set IK failure/freshness behavior, Pink multi-target IK, and plan/preview/execute whole-set scope. diff --git a/openspec/changes/add-viser-planning-target-set/specs/manipulation-planning-groups/spec.md b/openspec/changes/add-viser-planning-target-set/specs/manipulation-planning-groups/spec.md new file mode 100644 index 0000000000..e50237ad44 --- /dev/null +++ b/openspec/changes/add-viser-planning-target-set/specs/manipulation-planning-groups/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Whole-set pose target evaluation + +DimOS SHALL evaluate pose targets for a planning target set and return a global selected joint target when evaluation succeeds. + +#### Scenario: dual-arm pose targets evaluate to global joints +- **GIVEN** a planning target set includes `left_arm/manipulator` and `right_arm/manipulator` +- **AND** each selected group has an assigned pose target +- **WHEN** DimOS evaluates the pose target set +- **THEN** DimOS returns a target joint state using global joint names for both selected groups +- **AND** the result can be used as the goal for joint-target planning + +#### Scenario: auxiliary group participates without pose target +- **GIVEN** a planning target set includes one pose-targeted group and one auxiliary group +- **WHEN** DimOS evaluates the pose target set +- **THEN** the auxiliary group's selected joints participate in the returned target joint state +- **AND** DimOS does not require a direct pose target for the auxiliary group + +### Requirement: Multi-target Pink IK behavior + +DimOS SHALL support multi-target pose evaluation with Pink IK for same-robot and cross-robot planning target sets. + +#### Scenario: same robot multiple frame targets +- **GIVEN** one robot has multiple selected pose-targetable planning groups +- **WHEN** DimOS evaluates pose targets for those groups using Pink IK +- **THEN** DimOS solves the targets as one same-robot multi-frame IK problem +- **AND** returns one global selected joint target for the effective planning group selection + +#### Scenario: cross robot pose targets +- **GIVEN** a planning target set contains pose targets for planning groups on different robots +- **WHEN** DimOS evaluates the target set using Pink IK +- **THEN** DimOS evaluates the targets per robot model as needed +- **AND** combines successful results into one global selected joint target for the whole target set + +#### Scenario: IK does not own collision semantics +- **GIVEN** Pink IK returns a kinematically valid target joint state +- **WHEN** collision validation or path planning is required +- **THEN** DimOS performs collision checks through the planning world or planner responsibilities +- **AND** the IK result alone is not treated as proof that execution is collision-free + +### Requirement: Whole-set joint target evaluation + +DimOS SHALL evaluate joint targets for a planning target set as one whole selected target. + +#### Scenario: joint target evaluation returns poses for selected groups +- **GIVEN** a planning target set has global target joints for selected planning groups +- **WHEN** DimOS evaluates the joint target set +- **THEN** DimOS returns whole-set validity +- **AND** returns target poses for pose-targetable selected groups when available + +#### Scenario: invalid joint target blocks whole set +- **GIVEN** any selected planning group has missing, extra, or invalid target joints +- **WHEN** DimOS evaluates the joint target set +- **THEN** the whole target set is invalid +- **AND** planning is not enabled for the target set + +### Requirement: Target-set seed continuity + +DimOS SHALL use last valid target joints as the preferred seed for subsequent whole-set IK evaluation. + +#### Scenario: current state initializes target set +- **GIVEN** a planning group is newly added to a target set +- **WHEN** DimOS initializes target-set evaluation state +- **THEN** DimOS uses current joints for that group as the initial target and seed + +#### Scenario: subsequent IK uses last valid target +- **GIVEN** a planning target set has a last valid solved target joint state +- **WHEN** a subsequent pose edit triggers IK evaluation +- **THEN** DimOS uses the last valid target joints as the preferred seed +- **AND** planning still uses actual current state as the start when a plan is requested diff --git a/openspec/changes/add-viser-planning-target-set/specs/viser-planning-target-set/spec.md b/openspec/changes/add-viser-planning-target-set/specs/viser-planning-target-set/spec.md new file mode 100644 index 0000000000..d027115563 --- /dev/null +++ b/openspec/changes/add-viser-planning-target-set/specs/viser-planning-target-set/spec.md @@ -0,0 +1,115 @@ +## ADDED Requirements + +### Requirement: Planning target set selection + +Viser SHALL let users establish a planning target set by selecting one or more manipulation planning groups. + +#### Scenario: select multiple manipulator groups +- **GIVEN** a Viser manipulation scene contains `left_arm/manipulator` and `right_arm/manipulator` +- **WHEN** the user selects both planning groups +- **THEN** Viser treats them as one planning target set +- **AND** subsequent IK, feasibility, planning, preview, execute, and stale-state checks are scoped to the whole target set + +#### Scenario: select all manipulators convenience action +- **GIVEN** a Viser manipulation scene contains multiple manipulator planning groups +- **WHEN** the user chooses the select-all-manipulators action +- **THEN** all manipulator planning groups become members of the current planning target set +- **AND** the user can still adjust the selection explicitly + +### Requirement: Group-keyed target controls + +Viser SHALL key pose target controls by Planning Group ID for selected pose-targetable planning groups. + +#### Scenario: selected pose-targetable group shows gizmo +- **GIVEN** a selected planning group has a pose target frame +- **WHEN** Viser renders the planning target set +- **THEN** Viser shows a target gizmo associated with that Planning Group ID +- **AND** moving the gizmo edits that group's pose target within the whole target set + +#### Scenario: unselected group hides gizmo +- **GIVEN** a planning group is not part of the planning target set +- **WHEN** Viser renders target controls +- **THEN** Viser does not show a target gizmo for that group +- **AND** that group does not contribute target joints to the planning target set + +#### Scenario: auxiliary group has no assigned gizmo +- **GIVEN** a planning group is selected as an auxiliary member of the planning target set +- **WHEN** Viser renders target controls +- **THEN** Viser does not assign a direct pose gizmo to that auxiliary group +- **AND** the auxiliary group still participates in whole-set IK, feasibility, planning, preview, and execute behavior + +### Requirement: Target initialization from current state + +Viser SHALL initialize newly selected planning groups from current robot state. + +#### Scenario: selecting a group is motion-neutral +- **GIVEN** a planning group is not selected +- **WHEN** the user adds the group to the planning target set +- **THEN** Viser initializes that group's target joints from current joints +- **AND** if the group is pose-targetable, its target gizmo starts at the current group pose +- **AND** adding the group alone does not create a motion target away from current state + +### Requirement: Whole-set target authoring + +Viser SHALL treat pose gizmos and joint controls as views over one planning target set. + +#### Scenario: pose edit updates target joints +- **GIVEN** a planning target set has one or more selected pose-targetable groups +- **WHEN** the user moves any selected group's target gizmo +- **THEN** Viser requests whole-set IK evaluation for all active pose targets +- **AND** Viser updates the target-set joint controls from the returned global target joints when evaluation succeeds + +#### Scenario: joint edit updates pose targets +- **GIVEN** a planning target set has target joints and pose-targetable groups +- **WHEN** the user edits target joints +- **THEN** Viser requests whole-set joint target evaluation +- **AND** Viser updates visible pose gizmos from the evaluated group poses when available + +### Requirement: Whole-set status and failures + +Viser SHALL expose one canonical whole-set status for target validity and plan readiness. + +#### Scenario: IK failure keeps last valid target +- **GIVEN** a planning target set has a last valid solved target joint state +- **WHEN** realtime IK evaluation fails for a subsequent gizmo edit +- **THEN** Viser keeps the last valid target joints +- **AND** marks the planning target set invalid or stale +- **AND** disables planning until a whole-set evaluation succeeds again + +#### Scenario: per-group diagnostics are explanatory +- **GIVEN** a planning target set evaluation returns diagnostics for individual groups +- **WHEN** Viser displays target-set status +- **THEN** the whole-set status controls whether planning is enabled +- **AND** per-group diagnostics are displayed only as explanatory details + +### Requirement: Whole-set planning actions + +Viser SHALL plan, preview, execute, clear, and report freshness for the whole planning target set. + +#### Scenario: plan target set through joint targets +- **GIVEN** a planning target set has valid global target joints +- **WHEN** the user requests planning +- **THEN** Viser requests joint-target planning for the selected planning groups +- **AND** the request represents the whole target set rather than an individual robot or group + +#### Scenario: preview and execute full generated plan +- **GIVEN** planning succeeds for a planning target set +- **WHEN** the user previews or executes from Viser +- **THEN** Viser acts on the full generated plan for the target set +- **AND** the normal UI does not expose per-robot or per-group preview/execute actions + +#### Scenario: execute requires whole-set freshness +- **GIVEN** a generated plan was created for a planning target set +- **WHEN** current joints for any selected planning group drift from the plan start snapshot beyond tolerance +- **THEN** Viser marks the plan stale for the whole target set +- **AND** disables execute for the whole plan + +### Requirement: URDF-authored visual placement + +Viser SHALL rely on authored URDF/xacro placement for robot visuals in this workflow. + +#### Scenario: dual xArm visual placement is not double-applied +- **GIVEN** dual xArm robot models encode placement in their authored URDF/xacro output +- **WHEN** Viser registers the robots for visualization +- **THEN** Viser renders the prepared URDFs as authored +- **AND** Viser does not apply `base_pose` as an additional implicit visual transform diff --git a/openspec/changes/add-viser-planning-target-set/tasks.md b/openspec/changes/add-viser-planning-target-set/tasks.md new file mode 100644 index 0000000000..bfeffc9220 --- /dev/null +++ b/openspec/changes/add-viser-planning-target-set/tasks.md @@ -0,0 +1,45 @@ +## 1. Target-set evaluation model and module boundary + +- [x] 1.1 Define a target-set evaluation result shape with whole-set status, message, group IDs, global target joints, optional group diagnostics, and pose outputs for pose-targetable groups. +- [x] 1.2 Add ManipulationModule whole-set pose evaluation for `Mapping[PlanningGroupID, PoseStamped]` plus auxiliary group IDs, returning global selected joint targets. +- [x] 1.3 Add ManipulationModule whole-set joint evaluation for `Mapping[PlanningGroupID, JointState]`, including exact target validation and FK pose outputs for pose-targetable groups. +- [x] 1.4 Update the in-process Viser adapter to expose whole-set evaluation, target-set planning, whole-plan preview, and whole-plan execution helpers. +- [x] 1.5 Preserve existing one-robot behavior as the one-planning-group target-set case. + +## 2. Pink multi-target IK + +- [x] 2.1 Extend Pink IK pose-target solving to accept multiple pose-targeted planning groups in one request. +- [x] 2.2 Implement same-robot multi-frame Pink solving with multiple frame tasks in one Pink configuration. +- [x] 2.3 Implement cross-robot grouping for Pink IK by solving per robot model and combining results into one global selected joint target. +- [x] 2.4 Ensure auxiliary planning groups participate in the selected joints and seed/target result without receiving direct frame tasks. +- [x] 2.5 Keep collision semantics outside IK; surface kinematic success separately from collision/path-planning success. +- [x] 2.6 Add Pink IK tests for single-target regression, same-robot multi-frame targets, cross-robot targets, auxiliary groups, and global joint-name output. + +## 3. Viser target-set UI and scene + +- [x] 3.1 Replace robot-centric panel state with Planning Target Set state: selected group IDs, pose targets, global target joints, last valid target joints, whole-set status, diagnostics, plan status, and start snapshots. +- [x] 3.2 Add a planning-group checklist and a select-all-manipulators action. +- [x] 3.3 Make target controls planning-group-keyed; show gizmos only for selected pose-targetable groups and hide gizmos for unselected or auxiliary-only groups. +- [x] 3.4 Render one grouped Target Set joint panel with sections per selected planning group and one whole-set status/action row. +- [x] 3.5 Make pose gizmo edits trigger latest-request-wins whole-set IK evaluation, updating target joints on success and keeping last valid target joints on failure. +- [x] 3.6 Make joint edits trigger whole-set joint evaluation and update visible pose gizmos through FK outputs. +- [x] 3.7 Make Plan/Preview/Execute/Clear actions operate on the whole target set, with no normal per-robot or per-group action controls. +- [x] 3.8 Keep Viser robot placement URDF-authored; do not apply `base_pose` during scene registration. + +## 4. Documentation + +- [x] 4.1 Update manipulation/Viser user documentation with the Planning Target Set workflow and xArm launch examples. +- [x] 4.2 Document auxiliary groups as selected target-set members without assigned gizmos. +- [x] 4.3 Document that Viser uses URDF-authored placement and does not implicitly apply `base_pose`. +- [x] 4.4 Update contributor/coding-agent docs only if they currently describe robot-centric Viser planning or per-robot preview/execute state. + +## 5. Verification + +- [x] 5.1 Run `openspec validate add-viser-planning-target-set`. +- [x] 5.2 Run focused Pink IK tests, including new multi-target cases. +- [x] 5.3 Run focused Viser tests for target-set state, group-keyed gizmos, whole-set status, plan freshness, and whole-plan preview/execute actions. +- [x] 5.4 Run manipulation unit tests covering module whole-set evaluation and planning through joint targets. +- [ ] 5.5 Run xArm single-robot Viser manual QA with `xarm7-planner-coordinator` to verify the one-group workflow still works. +- [ ] 5.6 Run dual xArm mock Viser manual QA to verify selecting both manipulators, moving both gizmos, planning, previewing, and clearing the whole target set. +- [x] 5.7 Run docs validation commands for changed docs, including `doclinks` when available and `md-babel-py run ` for executable snippets. +- [x] 5.8 Run ruff/focused lint checks for touched manipulation and visualization files. From a38a90c6bfcc89282f68f85f66d78c6bc2f6c518 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 10:56:13 -0700 Subject: [PATCH 053/110] fix: stabilize viser ik target solving --- dimos/manipulation/manipulation_module.py | 37 +++-- .../planning/kinematics/pink_ik.py | 83 ++++++++-- .../planning/kinematics/test_pink_ik.py | 100 ++++++++++++ dimos/manipulation/planning/spec/config.py | 6 +- .../manipulation/planning/utils/mesh_utils.py | 58 ++++++- .../planning/utils/test_mesh_utils.py | 47 ++++++ .../planning/world/drake_world.py | 3 + dimos/manipulation/test_manipulation_unit.py | 29 ++++ .../visualization/viser/adapter.py | 10 +- .../visualization/viser/config.py | 7 + dimos/manipulation/visualization/viser/gui.py | 40 ++++- .../manipulation/visualization/viser/scene.py | 71 ++++++++- .../manipulation/visualization/viser/state.py | 1 + .../viser/test_viser_visualization.py | 146 +++++++++++++++++- dimos/robot/catalog/test_ufactory.py | 40 +++++ dimos/robot/catalog/ufactory.py | 22 ++- dimos/robot/config.py | 5 +- 17 files changed, 654 insertions(+), 51 deletions(-) create mode 100644 dimos/manipulation/planning/utils/test_mesh_utils.py create mode 100644 dimos/robot/catalog/test_ufactory.py diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 9e10f2f794..654d9ce970 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -1009,7 +1009,9 @@ def evaluate_joint_target( "joint_state": target, } - def evaluate_pose_target(self, pose: Pose, robot_name: RobotName) -> TargetEvaluation: + def evaluate_pose_target( + self, pose: Pose, robot_name: RobotName, check_collision: bool = True + ) -> TargetEvaluation: """Evaluate a Cartesian target for visualization without planning a path.""" robot_id = self.robot_id_for_name(robot_name) if robot_id is None: @@ -1037,10 +1039,15 @@ def evaluate_pose_target(self, pose: Pose, robot_name: RobotName) -> TargetEvalu "message": "Planning is not initialized or current state is unavailable", "collision_free": False, } - ik = self._solve_ik_for_pose(robot_id, pose, current, check_collision=True) + ik = self._solve_ik_for_pose(robot_id, pose, current, check_collision=check_collision) joint_state = JointState(ik.joint_state) if ik.is_success() and ik.joint_state else None - collision_free = bool( - joint_state is not None and self._world_monitor.is_state_valid(robot_id, joint_state) + collision_free = ( + bool(joint_state is not None) + if not check_collision + else bool( + joint_state is not None + and self._world_monitor.is_state_valid(robot_id, joint_state) + ) ) return { "success": joint_state is not None and collision_free, @@ -1159,6 +1166,7 @@ def _evaluate_global_target_set( message: str = "Target set is feasible", position_error: float = 0.0, orientation_error: float = 0.0, + check_collision: bool = True, ) -> TargetSetEvaluation: """Evaluate whole-set collision and pose outputs for global target joints.""" if self._world_monitor is None: @@ -1204,15 +1212,22 @@ def _evaluate_global_target_set( diagnostics: dict[PlanningGroupID, str] = {} selection = self._world_monitor.planning_groups.select(group_ids) for robot_name, (robot_id, _, local_target) in local_targets.items(): - robot_collision_free = self._world_monitor.is_state_valid(robot_id, local_target) + robot_collision_free = ( + self._world_monitor.is_state_valid(robot_id, local_target) + if check_collision + else True + ) collision_free = collision_free and robot_collision_free for group in selection.groups: if group.robot_name == robot_name: - diagnostics[group.id] = ( - "Target is collision-free" - if robot_collision_free - else "Target is in collision" - ) + if not check_collision: + diagnostics[group.id] = "Target collision check skipped" + else: + diagnostics[group.id] = ( + "Target is collision-free" + if robot_collision_free + else "Target is in collision" + ) return { "success": collision_free, @@ -1278,6 +1293,7 @@ def evaluate_pose_target_set( pose_targets: Mapping[PlanningGroupID | PlanningGroup, Pose | PoseStamped], auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup] = (), seed: JointState | None = None, + check_collision: bool = True, ) -> TargetSetEvaluation: """Evaluate pose targets for a whole planning target set using configured IK.""" if self._world_monitor is None or self._kinematics is None: @@ -1353,6 +1369,7 @@ def stamped_pose(pose: Pose | PoseStamped) -> PoseStamped: message=ik.message, position_error=ik.position_error, orientation_error=ik.orientation_error, + check_collision=check_collision, ) @rpc diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index 43c0b9b5d9..19b2a73a4d 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -85,6 +85,10 @@ class _PinkRobotContext: mapping: _JointMapping +class _CurrentStateRequiredError(ValueError): + """Raised when normalizing a seed requires the world's current state.""" + + class PinkIK: """Pink task/QP IK solver implementing the planning ``KinematicsSpec`` contract. @@ -221,7 +225,7 @@ def solve_pose_targets( world=world, robot_id=robot_id, pose_targets={group: pose_targets[group] for group in robot_pose_groups}, - seed=_seed_for_robot(world, robot_id, seed), + seed=_seed_for_robot_with_world_fallback(world, robot_id, seed), position_tolerance=position_tolerance, orientation_tolerance=orientation_tolerance, check_collision=check_collision, @@ -232,7 +236,7 @@ def solve_pose_targets( else: result = IKResult( status=IKStatus.SUCCESS, - joint_state=_seed_for_robot(world, robot_id, seed), + joint_state=_seed_for_robot_with_world_fallback(world, robot_id, seed), message="Auxiliary group retained seed state", ) joint_state = result.joint_state @@ -544,8 +548,12 @@ def _build_robot_context( package_paths=config.package_paths, xacro_args=config.xacro_args, convert_meshes=config.auto_convert_meshes, + strip_world_joint_child_link=config.base_link + if config.strip_model_world_joint + else None, ) model = pinocchio.buildModelFromUrdf(str(prepared_path)) + model = _lock_uncontrolled_model_joints(pinocchio, model, config) data = model.createData() target_frame = frame_name or config.end_effector_link @@ -669,6 +677,30 @@ def _build_joint_mapping(model: Any, config: RobotModelConfig) -> _JointMapping: ) +def _lock_uncontrolled_model_joints( + pinocchio: ModuleType, model: Any, config: RobotModelConfig +) -> Any: + """Return a Pinocchio model reduced to the joints controlled by config.""" + controlled_joint_names = set(config.joint_names) + lock_joint_ids: list[int] = [] + for joint_id, model_joint_name in enumerate(model.names): + if joint_id == 0 or model_joint_name in controlled_joint_names: + continue + joint = model.joints[joint_id] + if int(getattr(joint, "nq", 1)) > 0: + lock_joint_ids.append(joint_id) + + if not lock_joint_ids: + return model + + logger.debug( + "Reducing Pink IK model '%s' by locking uncontrolled joints: %s", + config.name, + [model.names[joint_id] for joint_id in lock_joint_ids], + ) + return pinocchio.buildReducedModel(model, lock_joint_ids, pinocchio.neutral(model)) + + def _get_joint_id(model: Any, joint_name: str) -> int: if hasattr(model, "existJointName") and not model.existJointName(joint_name): raise ValueError(_missing_joint_message(model, joint_name)) @@ -772,15 +804,16 @@ def _collision_failure(result: IKResult) -> IKResult: ) -def _seed_for_robot( - world: WorldSpec, robot_id: WorldRobotID, seed: JointState | None +def _seed_for_robot_config( + config: RobotModelConfig, + seed: JointState | None, + current_state: JointState | None = None, ) -> JointState: """Return a full local seed state for one robot from local/global seed input.""" - config = world.get_robot_config(robot_id) - with world.scratch_context() as ctx: - current = world.get_joint_state(ctx, robot_id) if seed is None: - return JointState(current) + if current_state is None: + raise _CurrentStateRequiredError("Current joint state is required when seed is absent") + return JointState(current_state) if not seed.name: if len(seed.position) == len(config.joint_names): return JointState({"name": list(config.joint_names), "position": list(seed.position)}) @@ -791,21 +824,47 @@ def _seed_for_robot( if len(seed.name) != len(seed.position): raise ValueError(f"Seed has {len(seed.name)} names but {len(seed.position)} positions") seed_by_name = dict(zip(seed.name, seed.position, strict=True)) - current_by_name = dict(zip(current.name, current.position, strict=True)) positions: list[float] = [] + missing_local_names: list[str] = [] for local_name in config.joint_names: global_name = make_global_joint_name(config.name, local_name) if local_name in seed_by_name: positions.append(float(seed_by_name[local_name])) elif global_name in seed_by_name: positions.append(float(seed_by_name[global_name])) - elif local_name in current_by_name: - positions.append(float(current_by_name[local_name])) else: - raise ValueError(f"Seed/current state is missing joint '{local_name}'") + positions.append(0.0) + missing_local_names.append(local_name) + if missing_local_names: + if current_state is None: + missing = ", ".join(repr(name) for name in missing_local_names) + raise _CurrentStateRequiredError( + f"Current joint state is required for missing joints: {missing}" + ) + current = current_state + current_by_name = dict(zip(current.name, current.position, strict=True)) + for index, local_name in enumerate(config.joint_names): + if local_name not in missing_local_names: + continue + if local_name not in current_by_name: + raise ValueError(f"Seed/current state is missing joint '{local_name}'") + positions[index] = float(current_by_name[local_name]) return JointState({"name": list(config.joint_names), "position": positions}) +def _seed_for_robot_with_world_fallback( + world: WorldSpec, robot_id: WorldRobotID, seed: JointState | None +) -> JointState: + """Normalize a robot seed, reading world state only when the seed is incomplete.""" + config = world.get_robot_config(robot_id) + try: + return _seed_for_robot_config(config, seed) + except _CurrentStateRequiredError: + with world.scratch_context() as ctx: + current = world.get_joint_state(ctx, robot_id) + return _seed_for_robot_config(config, seed, current) + + def _robot_ids_by_name( world: WorldSpec, robot_names: tuple[RobotName, ...] ) -> dict[RobotName, WorldRobotID]: diff --git a/dimos/manipulation/planning/kinematics/test_pink_ik.py b/dimos/manipulation/planning/kinematics/test_pink_ik.py index bfb0af604b..1ec9b7bd1b 100644 --- a/dimos/manipulation/planning/kinematics/test_pink_ik.py +++ b/dimos/manipulation/planning/kinematics/test_pink_ik.py @@ -32,8 +32,10 @@ PinkIKConfig, PinkIKDependencyError, _build_joint_mapping, + _lock_uncontrolled_model_joints, _PinkModules, _PinkRobotContext, + _seed_for_robot_config, _seed_positions_for_mapping, ) from dimos.manipulation.planning.spec.config import RobotModelConfig @@ -173,6 +175,15 @@ def _robot_config() -> RobotModelConfig: ) +def _pose_stamped(x: float, y: float, z: float, yaw: float = 0.0) -> PoseStamped: + half_yaw = yaw / 2.0 + return PoseStamped( + frame_id="world", + position=Vector3(x, y, z), + orientation=Quaternion(0.0, 0.0, float(np.sin(half_yaw)), float(np.cos(half_yaw))), + ) + + class _TestPinkIK(PinkIK): def __init__(self, converge: bool = True) -> None: self.config = PinkIKConfig(max_iterations=3) @@ -367,6 +378,15 @@ def test_joint_order_mapping_uses_names_not_positions() -> None: assert _seed_positions_for_mapping(seed, mapping).tolist() == [10.0, 20.0, 30.0] +def test_seed_for_robot_config_uses_complete_global_seed_without_world() -> None: + seed = JointState(name=["arm/joint_a", "arm/joint_b", "arm/joint_c"], position=[1.0, 2.0, 3.0]) + + result = _seed_for_robot_config(_robot_config(), seed) + + assert result.name == ["joint_a", "joint_b", "joint_c"] + assert result.position == [1.0, 2.0, 3.0] + + def test_mapping_failure_for_missing_joint() -> None: config = _robot_config() config.joint_names = ["joint_a", "missing", "joint_c"] @@ -375,6 +395,31 @@ def test_mapping_failure_for_missing_joint() -> None: _build_joint_mapping(_FakeModel(), config) +def test_uncontrolled_urdf_joints_are_locked_out_of_pink_model() -> None: + pinocchio = ModuleType("pinocchio") + model = _FakeModel() + model.names.append("gripper_joint") + model.joints.append(_FakeJoint(3)) + reduced_model = _FakeModel() + seen_locked_joint_ids: list[list[int]] = [] + + def build_reduced_model( + input_model: _FakeModel, locked_joint_ids: list[int], reference: np.ndarray + ) -> _FakeModel: + assert input_model is model + np.testing.assert_allclose(reference, np.zeros(model.nq)) + seen_locked_joint_ids.append(list(locked_joint_ids)) + return reduced_model + + pinocchio.neutral = lambda input_model: np.zeros(input_model.nq) # type: ignore[attr-defined] + pinocchio.buildReducedModel = build_reduced_model # type: ignore[attr-defined] + + result = _lock_uncontrolled_model_joints(pinocchio, model, _robot_config()) + + assert result is reduced_model + assert seen_locked_joint_ids == [[4]] + + def test_solve_single_returns_successful_ik_result() -> None: ik = _pink_ik(converge=True) target = np.eye(4) @@ -546,6 +591,61 @@ def fake_solve_multi_frame(**kwargs: object) -> IKResult: assert result.joint_state.position == [0.1, 0.2, 0.3] +def test_target_in_model_frame_converts_world_pose_through_robot_base() -> None: + ik = _pink_ik(converge=True) + config = _robot_config() + config.base_pose = _pose_stamped(1.0, 2.0, 0.0, yaw=np.pi / 2.0) + target_world = _pose_stamped(0.8, 2.1, 0.3, yaw=np.pi / 2.0) + + target_model = ik._target_in_model_frame(config, target_world) + + np.testing.assert_allclose(target_model[:3, 3], [0.1, 0.2, 0.3], atol=1e-12) + np.testing.assert_allclose(target_model[:3, :3], np.eye(3), atol=1e-12) + + +def test_solve_pose_targets_passes_world_target_to_solver_in_model_frame( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ik = _pink_ik(converge=True) + context = _context() + context.frame_name = "wrist_tool" + context.frame_id = 1 + ik._robot_contexts = {"robot:wrist_tool": context} + world = _FakeWorld(collision_free=True) + world.config.base_pose = _pose_stamped(1.0, 2.0, 0.0, yaw=np.pi / 2.0) + seen_target_models: list[np.ndarray] = [] + + def fake_solve_single(**kwargs: object) -> IKResult: + target_model = cast("np.ndarray", kwargs["target_model"]) + seen_target_models.append(target_model) + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState( + name=["joint_a", "joint_b", "joint_c"], position=[0.1, 0.2, 0.3] + ), + position_error=0.0, + orientation_error=0.0, + iterations=1, + ) + + monkeypatch.setattr(ik, "_solve_single", fake_solve_single) + + result = ik.solve_pose_targets( + world=cast("Any", world), + pose_targets={world.groups["arm/wrist"]: _pose_stamped(0.8, 2.1, 0.3, yaw=np.pi / 2.0)}, + seed=JointState( + {"name": ["arm/joint_a", "arm/joint_b", "arm/joint_c"], "position": [0.0, 0.0, 0.0]} + ), + check_collision=False, + max_attempts=1, + ) + + assert result.status == IKStatus.SUCCESS + assert len(seen_target_models) == 1 + np.testing.assert_allclose(seen_target_models[0][:3, 3], [0.1, 0.2, 0.3], atol=1e-12) + np.testing.assert_allclose(seen_target_models[0][:3, :3], np.eye(3), atol=1e-12) + + def test_solve_pose_targets_cross_robot_combines_global_joint_names( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index 32f32f5818..b7d4fd4b50 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -40,8 +40,9 @@ class RobotModelConfig(ModuleConfig): model_path: Path to robot model file (.urdf, .xacro, or .xml/MJCF) srdf_path: Optional path to SRDF file containing planning group definitions base_pose: Compatibility placement transform used by current Drake - world loading/welding. Prefer encoding new placement in the robot - model when possible. + world loading/welding. This is the canonical world placement for + robot instances; model-authored world/base attach joints are + stripped when strip_model_world_joint is true. joint_names: Ordered list of controllable joints in the local model namespace. This is not a planning group. end_effector_link: Compatibility robot-scoped end-effector link used by @@ -68,6 +69,7 @@ class RobotModelConfig(ModuleConfig): model_path: Path srdf_path: Path | None = None base_pose: PoseStamped = Field(default_factory=PoseStamped) + strip_model_world_joint: bool = False joint_names: list[str] end_effector_link: str | None = None base_link: str = "base_link" diff --git a/dimos/manipulation/planning/utils/mesh_utils.py b/dimos/manipulation/planning/utils/mesh_utils.py index 988a4e5e8e..adb4e8f36f 100644 --- a/dimos/manipulation/planning/utils/mesh_utils.py +++ b/dimos/manipulation/planning/utils/mesh_utils.py @@ -37,6 +37,7 @@ import shutil import tempfile from typing import TYPE_CHECKING +import xml.etree.ElementTree as ET from dimos.utils.logging_config import setup_logger @@ -55,6 +56,7 @@ def prepare_urdf_for_drake( package_paths: dict[str, Path] | None = None, xacro_args: dict[str, str] | None = None, convert_meshes: bool = False, + strip_world_joint_child_link: str | None = None, ) -> str: """Prepare a URDF/xacro file for use with Drake. @@ -68,6 +70,9 @@ def prepare_urdf_for_drake( package_paths: Dict mapping package names to filesystem paths xacro_args: Arguments to pass to xacro processor convert_meshes: Convert DAE/STL meshes to OBJ for Drake compatibility + strip_world_joint_child_link: If set, remove a fixed URDF joint from + world to this child link so callers can apply instance placement via + RobotModelConfig.base_pose instead of model-authored placement. Returns: Path to the prepared URDF file (may be cached) @@ -77,7 +82,9 @@ def prepare_urdf_for_drake( xacro_args = xacro_args or {} # Generate cache key - cache_key = _generate_cache_key(urdf_path, package_paths, xacro_args, convert_meshes) + cache_key = _generate_cache_key( + urdf_path, package_paths, xacro_args, convert_meshes, strip_world_joint_child_link + ) cache_path = _CACHE_DIR / cache_key / urdf_path.stem cache_path.mkdir(parents=True, exist_ok=True) cached_urdf = cache_path / f"{urdf_path.stem}.urdf" @@ -96,6 +103,9 @@ def prepare_urdf_for_drake( # Strip transmission blocks (Drake doesn't need them, and they can cause issues) urdf_content = _strip_transmission_blocks(urdf_content) + if strip_world_joint_child_link is not None: + urdf_content = _strip_fixed_world_joint(urdf_content, strip_world_joint_child_link) + # Resolve package:// URIs urdf_content = _resolve_package_uris(urdf_content, package_paths, cache_path) @@ -115,6 +125,7 @@ def _generate_cache_key( package_paths: dict[str, Path], xacro_args: dict[str, str], convert_meshes: bool, + strip_world_joint_child_link: str | None, ) -> str: """Generate a cache key for the URDF configuration. @@ -125,9 +136,12 @@ def _generate_cache_key( # Version number to invalidate cache when processing logic changes # Increment this when adding new processing steps (e.g., stripping transmission blocks) - processing_version = "v2" + processing_version = "v3" - key_data = f"{processing_version}:{urdf_path}:{mtime}:{sorted(package_paths.items())}:{sorted(xacro_args.items())}:{convert_meshes}" + key_data = ( + f"{processing_version}:{urdf_path}:{mtime}:{sorted(package_paths.items())}:" + f"{sorted(xacro_args.items())}:{convert_meshes}:{strip_world_joint_child_link}" + ) return hashlib.md5(key_data.encode()).hexdigest()[:16] @@ -175,6 +189,44 @@ def _strip_transmission_blocks(urdf_content: str) -> str: return result +def _strip_fixed_world_joint(urdf_content: str, child_link: str) -> str: + """Remove a fixed world-to-base joint so base_pose can own placement.""" + try: + root = ET.fromstring(urdf_content) + except ET.ParseError: + logger.warning("Could not parse URDF while stripping world joint", exc_info=True) + return urdf_content + + removed = False + for joint in list(root.findall("joint")): + if joint.attrib.get("type") != "fixed": + continue + parent = joint.find("parent") + child = joint.find("child") + if parent is None or child is None: + continue + if parent.attrib.get("link") == "world" and child.attrib.get("link") == child_link: + root.remove(joint) + removed = True + + if not removed: + return urdf_content + + referenced_links = set() + for joint in root.findall("joint"): + parent = joint.find("parent") + child = joint.find("child") + if parent is not None: + referenced_links.add(parent.attrib.get("link")) + if child is not None: + referenced_links.add(child.attrib.get("link")) + for link in list(root.findall("link")): + if link.attrib.get("name") == "world" and "world" not in referenced_links: + root.remove(link) + + return ET.tostring(root, encoding="unicode") + + def _resolve_package_uris( urdf_content: str, package_paths: dict[str, Path], diff --git a/dimos/manipulation/planning/utils/test_mesh_utils.py b/dimos/manipulation/planning/utils/test_mesh_utils.py new file mode 100644 index 0000000000..fa3004c7ce --- /dev/null +++ b/dimos/manipulation/planning/utils/test_mesh_utils.py @@ -0,0 +1,47 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import xml.etree.ElementTree as ET + +from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake + + +def test_prepare_urdf_can_strip_fixed_world_joint_for_base_pose_placement( + tmp_path, +) -> None: + urdf_path = tmp_path / "robot.urdf" + urdf_path.write_text( + """ + + + + + + + + + +""".strip() + ) + + prepared_path = prepare_urdf_for_drake( + urdf_path, + strip_world_joint_child_link="link_base", + ) + + root = ET.parse(prepared_path).getroot() + assert [joint.attrib["name"] for joint in root.findall("joint")] == [] + assert [link.attrib["name"] for link in root.findall("link")] == ["link_base"] diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 0c96a947ca..5d70c0024f 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -285,6 +285,9 @@ def _load_model(self, config: RobotModelConfig) -> Any: package_paths=config.package_paths, xacro_args=config.xacro_args, convert_meshes=config.auto_convert_meshes, + strip_world_joint_child_link=config.base_link + if config.strip_model_world_joint + else None, ) prepared_path_obj = Path(prepared_path) diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 5aef347767..a07ff1e5e2 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -964,6 +964,35 @@ def test_evaluate_pose_target_set_uses_whole_set_seed_and_auxiliary_groups(self) assert kwargs["seed"].name == ["left/j1", "left/j2"] assert kwargs["seed"].position == [0.1, 0.2] + def test_evaluate_pose_target_set_can_skip_collision_checking(self): + config = _make_robot_config("left", ["j1"], "task") + group = _make_global_group("left", "arm", ["j1"]) + module = _make_module_with_monitor(config) + module._world_monitor.planning_groups = _FakePlanningGroups([group]) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1"], position=[0.0] + ) + module._world_monitor.is_state_valid.return_value = False + module._world_monitor.get_group_pose.return_value = PoseStamped( + position=Vector3(x=1.0), orientation=Quaternion() + ) + module._kinematics = MagicMock() + module._kinematics.solve_pose_targets.return_value = IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState(name=["left/j1"], position=[1.0]), + message="solved", + ) + + result = module.evaluate_pose_target_set( + {"left/arm": Pose(position=Vector3(x=0.5), orientation=Quaternion())}, + check_collision=False, + ) + + assert result["success"] is True + assert result["collision_free"] is True + assert result["group_diagnostics"] == {"left/arm": "Target collision check skipped"} + module._world_monitor.is_state_valid.assert_not_called() + class TestGeneratedPlanProjection: def test_selected_joint_state_accepts_local_current_state_names(self): diff --git a/dimos/manipulation/visualization/viser/adapter.py b/dimos/manipulation/visualization/viser/adapter.py index 3824df6e31..0dec749d55 100644 --- a/dimos/manipulation/visualization/viser/adapter.py +++ b/dimos/manipulation/visualization/viser/adapter.py @@ -147,9 +147,13 @@ def evaluate_joint_target(self, joints: JointState, robot_name: RobotName) -> Ta ) return result - def evaluate_pose_target(self, pose: Pose, robot_name: RobotName) -> TargetEvaluation: + def evaluate_pose_target( + self, pose: Pose, robot_name: RobotName, *, check_collision: bool = True + ) -> TargetEvaluation: """Evaluate a Cartesian target through module/WorldMonitor helper boundaries.""" - result: TargetEvaluation = {**self._module.evaluate_pose_target(pose, robot_name)} + result: TargetEvaluation = { + **self._module.evaluate_pose_target(pose, robot_name, check_collision=check_collision) + } joint_state = result.get("joint_state") result["joint_state"] = copy_joint_state( joint_state if isinstance(joint_state, JointState) else None @@ -181,12 +185,14 @@ def evaluate_pose_target_set( pose_targets: dict[PlanningGroupID, Pose | PoseStamped], auxiliary_groups: Sequence[PlanningGroupID] = (), seed: JointState | None = None, + check_collision: bool = True, ) -> TargetSetEvaluation: result: TargetSetEvaluation = { **self._module.evaluate_pose_target_set( cast("dict[PlanningGroupID | PlanningGroup, Pose | PoseStamped]", pose_targets), auxiliary_groups=auxiliary_groups, seed=copy_joint_state(seed), + check_collision=check_collision, ) } target_joints = result.get("target_joints") diff --git a/dimos/manipulation/visualization/viser/config.py b/dimos/manipulation/visualization/viser/config.py index c7e66e4de1..d77cc66a15 100644 --- a/dimos/manipulation/visualization/viser/config.py +++ b/dimos/manipulation/visualization/viser/config.py @@ -54,6 +54,13 @@ class ViserVisualizationConfig(BaseModel): default=0.02, validation_alias=AliasChoices("current_match_tolerance", "viser_current_match_tolerance"), ) + target_evaluation_check_collision: bool = Field( + default=True, + validation_alias=AliasChoices( + "target_evaluation_check_collision", + "viser_target_evaluation_check_collision", + ), + ) allow_plan_execute: bool = False @property diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index f227536fa5..17a8551053 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -262,13 +262,17 @@ def _ensure_scene_controls(self) -> None: continue if group_id in self.state.auxiliary_group_ids: continue + handle_key = f"ee_control:{group_id}" + handle_exists = handle_key in self._handles ee_control = self.scene.ensure_target_controls( group_id, lambda target, gid=group_id: self._on_transform_update(gid, target), ) if ee_control is not None: - self._handles[f"ee_control:{group_id}"] = ee_control - pose = self.state.group_poses.get(group_id) or self.state.pose_targets.get(group_id) + self._handles[handle_key] = ee_control + if handle_exists: + continue + pose = self.state.pose_targets.get(group_id) if pose is not None: self._suppress_target_callbacks = True try: @@ -653,6 +657,7 @@ def _on_transform_update( group_ids=self.state.selected_group_ids, auxiliary_group_ids=self.state.auxiliary_group_ids, pose_targets=dict(self.state.pose_targets), + check_collision=self.config.target_evaluation_check_collision, ) ) self.refresh() @@ -706,6 +711,7 @@ def _handle_target_evaluation_request( request.pose_targets, auxiliary_groups=request.auxiliary_group_ids, seed=self.state.last_valid_target_joints, + check_collision=request.check_collision, ) if not request.joint_targets: return {"success": False, "status": "INVALID", "message": "No joint target"} @@ -738,6 +744,8 @@ def _apply_target_evaluation_result( for group_id, pose in group_poses.items() if isinstance(pose, Pose) } + if request.source == "joints" and success and collision_free: + self._sync_pose_targets_from_group_poses() group_diagnostics = result.get("group_diagnostics", {}) if isinstance(group_diagnostics, dict): self.state.group_diagnostics = { @@ -760,6 +768,34 @@ def _sync_controls_from_targets(self) -> None: # programmatic pose writes from delayed IK results can fight fast user # dragging and make the gizmo jump back. + def _sync_pose_targets_from_group_poses(self) -> None: + groups = self._group_info_by_id() + updated_group_ids: list[PlanningGroupID] = [] + for group_id, pose in self.state.group_poses.items(): + group = groups.get(group_id) + if group is None or not bool(group["has_pose_target"]): + continue + if group_id in self.state.auxiliary_group_ids: + continue + self.state.pose_targets[group_id] = pose + updated_group_ids.append(group_id) + first_group_id = next(iter(self.state.selected_group_ids), None) + if first_group_id is not None: + self.state.cartesian_target = self.state.pose_targets.get(first_group_id) + self._sync_scene_target_pose_controls(updated_group_ids) + + def _sync_scene_target_pose_controls(self, group_ids: list[PlanningGroupID]) -> None: + if self.scene is None: + return + self._suppress_target_callbacks = True + try: + for group_id in group_ids: + pose = self.state.pose_targets.get(group_id) + if pose is not None: + self.scene.set_target_pose(group_id, pose) + finally: + self._suppress_target_callbacks = False + def _update_status_text(self) -> None: current = self.state.current_joints status = [ diff --git a/dimos/manipulation/visualization/viser/scene.py b/dimos/manipulation/visualization/viser/scene.py index 251dac6126..fdfcc7fa56 100644 --- a/dimos/manipulation/visualization/viser/scene.py +++ b/dimos/manipulation/visualization/viser/scene.py @@ -31,6 +31,7 @@ try: from viser import ( + FrameHandle, GridHandle, MeshHandle, TransformControlsEvent, @@ -69,7 +70,7 @@ REFERENCE_GRID_CELL_COLOR = (44, 54, 58) REFERENCE_GRID_SECTION_COLOR = (90, 145, 165) -SceneHandle: TypeAlias = ViserUrdf | TransformControlsHandle | GridHandle | MeshHandle +SceneHandle: TypeAlias = ViserUrdf | TransformControlsHandle | GridHandle | MeshHandle | FrameHandle class _ColorHandle(Protocol): @@ -88,6 +89,7 @@ def __init__( self._configs_by_id: dict[str, RobotModelConfig] = {} self._urdfs: dict[str, ViserUrdf] = {} self._handles: dict[str, TransformControlsHandle] = {} + self._root_frames: dict[str, FrameHandle] = {} self._grid_handle: GridHandle | None = None self._grid_visible = True self._preview_visible: dict[str, bool] = {} @@ -255,7 +257,10 @@ def close(self) -> None: self._grid_handle = None for urdf in self._urdfs.values(): self._remove_scene_handle(urdf) + for frame in self._root_frames.values(): + self._remove_scene_handle(frame) self._urdfs.clear() + self._root_frames.clear() self._configs_by_id.clear() self._preview_visible.clear() self._target_tracks_current.clear() @@ -267,11 +272,7 @@ def _ensure_robot_urdfs(self, robot_id: str, config: RobotModelConfig) -> None: key = f"{robot_id}:{kind}" if key in self._urdfs: continue - root_node_name = { - "current": f"/robots/{robot_id}/current", - "target": f"/targets/{robot_id}/target", - "preview": f"/previews/{robot_id}/ghost", - }[kind] + root_node_name = self._urdf_root_node_name(robot_id, kind, config) mesh_color_override = { "current": None, "target": GOAL_ROBOT_MESH_COLOR, @@ -304,6 +305,64 @@ def prepared_urdf_path(self, config: RobotModelConfig) -> Path: package_paths=package_paths, xacro_args={str(key): str(value) for key, value in config.xacro_args.items()}, convert_meshes=bool(config.auto_convert_meshes), + strip_world_joint_child_link=str(config.base_link) + if bool(getattr(config, "strip_model_world_joint", False)) + else None, + ) + ) + + def _urdf_root_node_name(self, robot_id: str, kind: str, config: RobotModelConfig) -> str: + root_node_name = { + "current": f"/robots/{robot_id}/current", + "target": f"/targets/{robot_id}/target", + "preview": f"/previews/{robot_id}/ghost", + }[kind] + if not self._has_non_identity_base_pose(config): + return root_node_name + self._ensure_base_pose_frame(robot_id, kind, config) + return f"{root_node_name}/base_pose/urdf" + + def _ensure_base_pose_frame(self, robot_id: str, kind: str, config: RobotModelConfig) -> None: + key = f"{robot_id}:{kind}:base_pose" + if key in self._root_frames: + return + pose = config.base_pose + frame_name = { + "current": f"/robots/{robot_id}/current/base_pose", + "target": f"/targets/{robot_id}/target/base_pose", + "preview": f"/previews/{robot_id}/ghost/base_pose", + }[kind] + self._root_frames[key] = self.server.scene.add_frame( + frame_name, + show_axes=False, + position=( + float(pose.position.x), + float(pose.position.y), + float(pose.position.z), + ), + wxyz=( + float(pose.orientation.w), + float(pose.orientation.x), + float(pose.orientation.y), + float(pose.orientation.z), + ), + ) + + @staticmethod + def _has_non_identity_base_pose(config: RobotModelConfig) -> bool: + pose = getattr(config, "base_pose", None) + if pose is None: + return False + return any( + abs(value) > 1e-12 + for value in ( + float(pose.position.x), + float(pose.position.y), + float(pose.position.z), + float(pose.orientation.x), + float(pose.orientation.y), + float(pose.orientation.z), + float(pose.orientation.w) - 1.0, ) ) diff --git a/dimos/manipulation/visualization/viser/state.py b/dimos/manipulation/visualization/viser/state.py index bd28962b2d..f16e490ffb 100644 --- a/dimos/manipulation/visualization/viser/state.py +++ b/dimos/manipulation/visualization/viser/state.py @@ -226,6 +226,7 @@ class TargetEvaluationRequest: pose_targets: dict[PlanningGroupID, Pose] = field(default_factory=dict) joints: JointState | None = None joint_targets: dict[PlanningGroupID, JointState] = field(default_factory=dict) + check_collision: bool = True class TargetEvaluationWorker: diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index cedd3954b3..4b0f0872ed 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -47,6 +47,9 @@ ) from dimos.manipulation.visualization.viser.theme import _dimos_logo_data_url, apply_dimos_theme from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.sensor_msgs.JointState import JointState GuiCallback = Callable[[SimpleNamespace], None] @@ -167,7 +170,7 @@ def __init__(self) -> None: self.visible: object | None = None self.removed = False self.name = "" - self.kwargs: dict[str, float | bool] = {} + self.kwargs: dict[str, object] = {} def remove(self) -> None: self.removed = True @@ -207,6 +210,8 @@ class FakeServer: def __init__(self) -> None: self.scene = SimpleNamespace() self.scene.add_transform_controls = self.add_transform_controls + self.scene.add_frame = self.add_frame + self.frames = [] def add_transform_controls(self, path: str, *, scale: float) -> FakeTransformHandle: handle = FakeTransformHandle() @@ -214,6 +219,13 @@ def add_transform_controls(self, path: str, *, scale: float) -> FakeTransformHan handle.scale = scale return handle + def add_frame(self, name: str, **kwargs: object) -> FakeHandle: + handle = FakeHandle() + handle.name = name + handle.kwargs = kwargs + self.frames.append(handle) + return handle + class FakeGridServer(FakeServer): def __init__(self) -> None: @@ -224,7 +236,7 @@ def __init__(self) -> None: def add_grid(self, name: str, **kwargs: float | bool) -> FakeHandle: handle = FakeHandle() handle.name = name - handle.kwargs = kwargs + handle.kwargs = dict(kwargs) handle.visible = kwargs.get("visible") self.grids.append(handle) return handle @@ -439,7 +451,9 @@ def evaluate_joint_target(self, joints: JointState | None, robot_name: str) -> T "joint_state": joints, } - def evaluate_pose_target(self, _pose: Pose, _robot_name: str) -> TargetEvaluation: + def evaluate_pose_target( + self, _pose: Pose, _robot_name: str, *, check_collision: bool = True + ) -> TargetEvaluation: return { "success": False, "joint_state": None, @@ -448,6 +462,26 @@ def evaluate_pose_target(self, _pose: Pose, _robot_name: str) -> TargetEvaluatio "collision_free": False, } + def evaluate_pose_target_set( + self, + pose_targets: dict[str, Pose], + auxiliary_groups: Sequence[str] = (), + seed: JointState | None = None, + check_collision: bool = True, + ) -> TargetSetEvaluation: + target = JointState({"name": ["arm/j1", "arm/j2"], "position": [0.1, 0.2]}) + return { + "success": True, + "status": "FEASIBLE", + "message": "Target collision check skipped" + if not check_collision + else "Target is collision-free", + "collision_free": True, + "target_joints": target, + "group_ids": tuple(pose_targets) + tuple(auxiliary_groups), + "group_poses": dict(pose_targets), + } + def evaluate_joint_target_set( self, joint_targets: dict[str, JointState] ) -> TargetSetEvaluation: @@ -823,6 +857,48 @@ def test_target_ghost_is_visible_and_tracks_current_until_target_moves_it() -> N assert preview.cfg is None +def test_scene_parents_urdfs_under_base_pose_frame() -> None: + server = FakeServer() + root_node_names: list[str] = [] + + def make_urdf(*_: object, **kwargs: object) -> FakeViserUrdfWithMeshes: + root_node_names.append(str(kwargs["root_node_name"])) + return FakeViserUrdfWithMeshes(("joint1",)) + + scene = ViserManipulationScene(server, make_urdf, preview_fps=10.0) + scene.prepared_urdf_path = lambda config: "dummy.urdf" + config = SimpleNamespace( + name="arm", + model_path="/tmp/arm.urdf", + package_paths={}, + xacro_args={}, + auto_convert_meshes=False, + joint_names=["joint1"], + base_pose=PoseStamped( + position=Vector3(1.0, 2.0, 3.0), + orientation=Quaternion(0.0, 0.0, 0.0, 1.0), + ), + ) + + scene.register_robot("robot1", config) + + assert [frame.name for frame in server.frames] == [ + "/robots/robot1/current/base_pose", + "/targets/robot1/target/base_pose", + "/previews/robot1/ghost/base_pose", + ] + assert [frame.kwargs["position"] for frame in server.frames] == [ + (1.0, 2.0, 3.0), + (1.0, 2.0, 3.0), + (1.0, 2.0, 3.0), + ] + assert root_node_names == [ + "/robots/robot1/current/base_pose/urdf", + "/targets/robot1/target/base_pose/urdf", + "/previews/robot1/ghost/base_pose/urdf", + ] + + def test_preview_animation_uses_separate_colored_ghost_and_hides_after_playback() -> None: server = FakeServer() urdfs = [FakeViserUrdfWithMeshes(("joint1",)) for _ in range(3)] @@ -1346,12 +1422,14 @@ def test_gui_moves_joint_target_immediately_and_stores_evaluated_joint_solution( assert gui.state.target_joints is not None assert list(gui.state.target_joints.position) == [0.25, 0.75] + joint_bar_pose = Pose({"position": [0.4, 0.5, 0.6], "orientation": [0.0, 0.0, 0.0, 1.0]}) gui._apply_target_evaluation_result( fresh_request, { "success": True, "collision_free": True, "target_joints": adapter.joints_from_values(["arm/j1", "arm/j2"], [1.0, 2.0]), + "group_poses": {DEFAULT_GROUP_ID: joint_bar_pose}, }, ) assert gui.state.target_status == TargetStatus.FEASIBLE @@ -1360,6 +1438,7 @@ def test_gui_moves_joint_target_immediately_and_stores_evaluated_joint_solution( assert list(gui.state.target_joints.position) == [1.0, 2.0] assert [gui._joint_sliders[name].value for name in ("arm/j1", "arm/j2")] == [0.25, 0.75] assert target_updates[-1] == ("robot-1", ["j1", "j2"], [0.25, 0.75]) + assert target_pose_updates[-1] == (DEFAULT_GROUP_ID, joint_bar_pose) def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( @@ -1379,15 +1458,18 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( target_pose_updates = [] scene = SimpleNamespace( has_reference_grid=lambda: False, - ensure_target_controls=lambda *args: None, + ensure_target_controls=lambda *args: object(), set_target_joints=lambda *args: target_joint_updates.append(args) or True, set_target_pose=lambda *args: target_pose_updates.append(args), set_target_visual_state=lambda *args: None, ) gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) - gui.state.cartesian_target = Pose( - {"position": [0.1, 0.2, 0.3], "orientation": [0.0, 0.0, 0.0, 1.0]} - ) + gui._handles[f"ee_control:{DEFAULT_GROUP_ID}"] = object() + dragged_pose = Pose({"position": [0.1, 0.2, 0.3], "orientation": [0.0, 0.0, 0.0, 1.0]}) + solved_pose = Pose({"position": [0.4, 0.5, 0.6], "orientation": [0.0, 0.0, 0.0, 1.0]}) + gui.state.cartesian_target = dragged_pose + gui.state.pose_targets[DEFAULT_GROUP_ID] = dragged_pose + target_pose_updates.clear() request = TargetEvaluationRequest( sequence_id=1, source="cartesian", group_ids=(DEFAULT_GROUP_ID,) ) @@ -1399,6 +1481,7 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( "success": True, "collision_free": True, "target_joints": adapter.joints_from_values(["arm/j1", "arm/j2"], [1.0, 2.0]), + "group_poses": {DEFAULT_GROUP_ID: solved_pose}, }, ) @@ -1406,6 +1489,55 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( assert [gui._joint_sliders[name].value for name in ("arm/j1", "arm/j2")] == [1.0, 2.0] assert target_joint_updates[-1] == ("robot-1", ["j1", "j2"], [1.0, 2.0]) assert target_pose_updates == [] + assert gui.state.pose_targets[DEFAULT_GROUP_ID] is dragged_pose + assert gui.state.group_poses[DEFAULT_GROUP_ID] is solved_pose + + +def test_gui_can_disable_collision_check_for_cartesian_target_evaluation( + make_panel: Callable[..., ViserPanelGui], +) -> None: + current = FakeJointState(["j1", "j2"], position=[0.0, 0.0]) + config = make_robot_config(joint_names=["j1", "j2"], home_joints=[0.0, 0.0]) + module = FakeManipulationModule(_robots={"arm": ("robot-1", config, None)}) + world_monitor = SimpleNamespace( + get_current_joint_state=lambda robot_id: current, + is_state_stale=lambda robot_id, max_age=1.0: False, + is_state_valid=lambda robot_id, joint_state: False, + get_ee_pose=lambda robot_id, joint_state=None: Pose( + {"position": [0.0, 0.0, 0.0], "orientation": [0.0, 0.0, 0.0, 1.0]} + ), + ) + adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + scene = SimpleNamespace( + has_reference_grid=lambda: False, + ensure_target_controls=lambda *args: None, + set_target_joints=lambda *args: True, + set_target_pose=lambda *args: None, + set_target_visual_state=lambda *args: None, + ) + gui = make_panel( + FakeGuiServer(), + adapter, + ViserVisualizationConfig(panel_enabled=True, target_evaluation_check_collision=False), + scene, + ) + request = TargetEvaluationRequest( + sequence_id=1, + source="cartesian", + group_ids=(DEFAULT_GROUP_ID,), + pose_targets={ + DEFAULT_GROUP_ID: Pose( + {"position": [0.1, 0.2, 0.3], "orientation": [0.0, 0.0, 0.0, 1.0]} + ) + }, + check_collision=False, + ) + + result = gui._handle_target_evaluation_request(request) + + assert result["success"] is True + assert result["collision_free"] is True + assert result["message"] == "Target collision check skipped" def test_gui_collision_evaluation_marks_target_infeasible_and_colors_scene( diff --git a/dimos/robot/catalog/test_ufactory.py b/dimos/robot/catalog/test_ufactory.py new file mode 100644 index 0000000000..3875ff9c92 --- /dev/null +++ b/dimos/robot/catalog/test_ufactory.py @@ -0,0 +1,40 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Callable +import math + +import pytest + +from dimos.robot.catalog.ufactory import xarm6, xarm7 +from dimos.robot.config import RobotConfig + + +@pytest.mark.parametrize("factory", [xarm6, xarm7]) +def test_xarm_instance_offsets_are_encoded_only_in_base_pose( + factory: Callable[..., RobotConfig], +) -> None: + config = factory(name="arm", y_offset=0.5, pitch=0.25) + model_config = config.to_robot_model_config() + + assert model_config.xacro_args["attach_xyz"] == "0 0 0" + assert model_config.xacro_args["attach_rpy"] == "0 0 0" + assert model_config.base_pose.position.y == 0.5 + assert model_config.base_pose.orientation.x == 0.0 + assert model_config.base_pose.orientation.y == pytest.approx(math.sin(0.125)) + assert model_config.base_pose.orientation.z == 0.0 + assert model_config.base_pose.orientation.w == pytest.approx(math.cos(0.125)) + assert model_config.strip_model_world_joint is True diff --git a/dimos/robot/catalog/ufactory.py b/dimos/robot/catalog/ufactory.py index ddf93185dc..9c21585d2a 100644 --- a/dimos/robot/catalog/ufactory.py +++ b/dimos/robot/catalog/ufactory.py @@ -16,6 +16,7 @@ from __future__ import annotations +import math from typing import Any from dimos.robot.config import GripperConfig, RobotConfig @@ -51,6 +52,13 @@ ] +def _base_pose_from_offsets( + x_offset: float, y_offset: float, z_offset: float, pitch: float +) -> list[float]: + half_pitch = pitch / 2.0 + return [x_offset, y_offset, z_offset, 0.0, math.sin(half_pitch), 0.0, math.cos(half_pitch)] + + def xarm7( name: str = "arm", *, @@ -68,8 +76,8 @@ def xarm7( xacro_args: dict[str, str] = { "dof": "7", "limited": "true", - "attach_xyz": f"{x_offset} {y_offset} {z_offset}", - "attach_rpy": f"0 {pitch} 0", + "attach_xyz": "0 0 0", + "attach_rpy": "0 0 0", } if add_gripper: xacro_args["add_gripper"] = "true" @@ -83,7 +91,8 @@ def xarm7( "joint_names": [f"joint{i}" for i in range(1, 8)], "base_link": "link_base", "home_joints": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - "base_pose": [x_offset, y_offset, z_offset, 0, 0, 0, 1], + "base_pose": _base_pose_from_offsets(x_offset, y_offset, z_offset, pitch), + "strip_model_world_joint": True, "package_paths": {"xarm_description": LfsPath("xarm_description")}, "xacro_args": xacro_args, "auto_convert_meshes": True, @@ -120,8 +129,8 @@ def xarm6( xacro_args: dict[str, str] = { "dof": "6", "limited": "true", - "attach_xyz": f"{x_offset} {y_offset} {z_offset}", - "attach_rpy": f"0 {pitch} 0", + "attach_xyz": "0 0 0", + "attach_rpy": "0 0 0", } if add_gripper: xacro_args["add_gripper"] = "true" @@ -135,7 +144,8 @@ def xarm6( "joint_names": [f"joint{i}" for i in range(1, 7)], "base_link": "link_base", "home_joints": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - "base_pose": [x_offset, y_offset, z_offset, 0, 0, 0, 1], + "base_pose": _base_pose_from_offsets(x_offset, y_offset, z_offset, pitch), + "strip_model_world_joint": True, "package_paths": {"xarm_description": LfsPath("xarm_description")}, "xacro_args": xacro_args, "auto_convert_meshes": True, diff --git a/dimos/robot/config.py b/dimos/robot/config.py index e57859d9ea..ca73cb384f 100644 --- a/dimos/robot/config.py +++ b/dimos/robot/config.py @@ -83,8 +83,10 @@ class RobotConfig(BaseModel): ) home_joints: list[float] | None = None - # Compatibility planning placement. Prefer encoding placement in URDF/xacro/MJCF. + # Canonical planning placement. Robot models should describe intrinsic geometry; + # instance placement belongs here. base_pose: list[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) + strip_model_world_joint: bool = False # Planning max_velocity: float = 1.0 @@ -215,6 +217,7 @@ def to_robot_model_config(self) -> RobotModelConfig: model_path=self.model_path, srdf_path=self.srdf_path, base_pose=base_pose, + strip_model_world_joint=self.strip_model_world_joint, joint_names=joint_names, end_effector_link=legacy_end_effector_link, base_link=base_link, From 15ba00f6ff7b20fd9b20612473ac505bad12e5d2 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 11:48:31 -0700 Subject: [PATCH 054/110] fix: polish viser planning group panel --- dimos/manipulation/visualization/viser/gui.py | 369 +++++++++++------- .../manipulation/visualization/viser/scene.py | 17 +- .../viser/test_viser_visualization.py | 149 ++++++- 3 files changed, 377 insertions(+), 158 deletions(-) diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 17a8551053..1c690f3fec 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -19,7 +19,6 @@ from dimos.manipulation.planning.spec.models import PlanningGroupID from dimos.manipulation.visualization.types import ( PlanningGroupInfo, - RobotInfo, TargetEvaluation, TargetSetEvaluation, ) @@ -140,18 +139,22 @@ def refresh(self) -> None: BackendConnectionStatus.READY if robots else BackendConnectionStatus.WAITING_FOR_ROBOT ) if not self.state.selected_group_ids and groups and not self._default_group_initialized: - self.state.selected_group_ids = (str(groups[0]["id"]),) - self.state.selected_robot = str(groups[0]["robot_name"]) + first_pose_group = next( + (group for group in groups if bool(group["has_pose_target"])), groups[0] + ) + self.state.selected_group_ids = (str(first_pose_group["id"]),) self.state.target_status = TargetStatus.EMPTY self._default_group_initialized = True + self._sync_group_selection_state() self._initialize_selected_group_targets() self._build_joint_sliders() - self._sync_robot_dropdown(robots) - self._sync_group_checkboxes(groups) + self._sync_group_selector(groups) self._refresh_selected_robot_state() self._ensure_scene_controls() + self._sync_target_ghost_visibility() self._sync_preset_dropdown() self._update_status_text() + self._update_target_summary() self._update_control_state() def _build(self) -> None: @@ -162,41 +165,46 @@ def _build(self) -> None: self._build_panel_controls(gui) def _build_panel_controls(self, gui: GuiApi) -> None: - self._handles["status"] = gui.add_markdown("Starting manipulation panel...") - robots = self.adapter.list_robots() + self._handles["status"] = gui.add_markdown("**Status:** Ready") self._build_scene_controls(gui) - robot_dropdown = gui.add_dropdown( - "Robot", - options=robots or [""], - initial_value=robots[0] if robots else "", + self._handles["planning_groups_heading"] = gui.add_markdown( + "### Planning Groups\nChoose active target groups." ) - robot_dropdown.on_update(lambda event: self._select_robot(event.target.value)) - self._handles["robot"] = robot_dropdown - select_all_button = gui.add_button("Select all manipulators") + self._sync_group_selector(self.adapter.list_planning_groups()) + select_all_button = gui.add_button("Select all") select_all_button.on_click(lambda _: self._select_all_manipulators()) self._handles["select_all_manipulators"] = select_all_button + clear_selection_button = gui.add_button("Clear selection") + clear_selection_button.on_click(lambda _: self._clear_group_selection()) + self._handles["clear_group_selection"] = clear_selection_button + self._handles["target_heading"] = gui.add_markdown("### Target") preset_dropdown = gui.add_dropdown( - "Target Preset", + "Preset", options=["Select preset...", "Current"], initial_value="Select preset...", ) preset_dropdown.on_update(lambda event: self._apply_preset(event.target.value)) self._handles["preset"] = preset_dropdown + self._handles["target_summary"] = gui.add_markdown("Select a group to define a target.") + self._handles["actions_heading"] = gui.add_markdown("### Actions") plan_button = gui.add_button("Plan", disabled=True) plan_button.on_click(lambda _: self._submit_plan()) self._handles["plan"] = plan_button - preview_button = gui.add_button("Preview", disabled=True) - preview_button.on_click(lambda _: self._submit_preview()) - self._handles["preview"] = preview_button - execute_button = gui.add_button("Execute", disabled=True) - execute_button.on_click(lambda _: self._submit_execute()) - self._handles["execute"] = execute_button - cancel_button = gui.add_button("Cancel") - cancel_button.on_click(lambda _: self._submit_cancel()) - self._handles["cancel"] = cancel_button - clear_button = gui.add_button("Clear plan") - clear_button.on_click(lambda _: self._submit_clear()) - self._handles["clear"] = clear_button + more_actions = gui.add_folder("Plan controls", expand_by_default=False) + self._handles["actions_folder"] = more_actions + with more_actions: + preview_button = gui.add_button("Preview", disabled=True) + preview_button.on_click(lambda _: self._submit_preview()) + self._handles["preview"] = preview_button + execute_button = gui.add_button("Execute", disabled=True) + execute_button.on_click(lambda _: self._submit_execute()) + self._handles["execute"] = execute_button + cancel_button = gui.add_button("Cancel") + cancel_button.on_click(lambda _: self._submit_cancel()) + self._handles["cancel"] = cancel_button + clear_button = gui.add_button("Clear plan") + clear_button.on_click(lambda _: self._submit_clear()) + self._handles["clear"] = clear_button self._build_joint_sliders() def _build_scene_controls(self, gui: GuiApi) -> None: @@ -236,14 +244,7 @@ def _ensure_scene_controls(self) -> None: if self.scene is None: return groups = self._group_info_by_id() - selected = set(self.state.selected_group_ids) - active_pose_groups = { - group_id - for group_id in selected - if (group := groups.get(group_id)) is not None - and bool(group["has_pose_target"]) - and group_id not in self.state.auxiliary_group_ids - } + active_pose_groups = set(self._selected_pose_group_ids()) for key in [key for key in self._handles if key.startswith("ee_control:")]: group_id = key.split(":", 1)[1] if group_id in active_pose_groups: @@ -256,22 +257,19 @@ def _ensure_scene_controls(self) -> None: remove = getattr(handle, "remove", None) if callable(remove): remove() - for group_id in selected: + for group_id in active_pose_groups: group = groups.get(group_id) if group is None or not bool(group["has_pose_target"]): continue - if group_id in self.state.auxiliary_group_ids: - continue handle_key = f"ee_control:{group_id}" - handle_exists = handle_key in self._handles + if handle_key in self._handles: + continue ee_control = self.scene.ensure_target_controls( group_id, lambda target, gid=group_id: self._on_transform_update(gid, target), ) if ee_control is not None: self._handles[handle_key] = ee_control - if handle_exists: - continue pose = self.state.pose_targets.get(group_id) if pose is not None: self._suppress_target_callbacks = True @@ -281,10 +279,10 @@ def _ensure_scene_controls(self) -> None: self._suppress_target_callbacks = False def _build_joint_sliders(self) -> None: - if not self.state.selected_group_ids: - return gui = self.server.gui self._clear_joint_sliders() + if not self.state.selected_group_ids: + return groups = self._group_info_by_id() target_by_name: dict[str, float] = {} if self.state.target_joints is not None: @@ -378,59 +376,19 @@ def _remove_panel_handles(self) -> None: remove() self._handles.pop(key, None) - def _select_robot(self, robot_name: str) -> None: - if self._closed: - return - if (robot_name or None) == self.state.selected_robot: - self.refresh() - return - self.state.selected_robot = robot_name or None - groups = [ - group - for group in self.adapter.list_planning_groups() - if str(group["robot_name"]) == self.state.selected_robot - ] - self.state.selected_group_ids = (str(groups[0]["id"]),) if groups else () - self.state.auxiliary_group_ids = () - self.state.group_joint_targets.clear() - self.state.pose_targets.clear() - self.state.target_status = TargetStatus.EMPTY - self.state.feasibility.status = FeasibilityStatus.UNKNOWN - self.state.plan_state = PanelPlanState() - self._initialize_selected_group_targets() - self._build_joint_sliders() - self._sync_preset_dropdown() - self.refresh() - - def _sync_robot_dropdown(self, robots: list[str]) -> None: - handle = self._handles.get("robot") - if handle is None: - return - options = robots or [""] - for attr in ("options", "values"): - if hasattr(handle, attr): - try: - self._set_optional_handle_attr(handle, attr, options) - except Exception: - logger.warning("Could not set robot dropdown %s", attr, exc_info=True) - if hasattr(handle, "value") and self.state.selected_robot in robots: - try: - self._set_optional_handle_attr(handle, "value", self.state.selected_robot) - except Exception: - logger.warning("Could not set robot dropdown value", exc_info=True) - - def _sync_group_checkboxes(self, groups: list[PlanningGroupInfo]) -> None: + def _sync_group_selector(self, groups: list[PlanningGroupInfo]) -> None: seen_keys: set[str] = set() selected = set(self.state.selected_group_ids) - for group in groups: + for group in sorted( + groups, key=lambda item: (not bool(item["has_pose_target"]), str(item["id"])) + ): group_id = str(group["id"]) key = f"group:{group_id}" seen_keys.add(key) handle = self._handles.get(key) + label = self._group_selector_label(group) if handle is None: - handle = self.server.gui.add_checkbox( - f"Group {group_id}", initial_value=group_id in selected - ) + handle = self.server.gui.add_checkbox(label, initial_value=group_id in selected) handle.on_update( lambda event, gid=group_id: self._set_group_selected( gid, bool(event.target.value) @@ -447,6 +405,17 @@ def _sync_group_checkboxes(self, groups: list[PlanningGroupInfo]) -> None: if callable(remove): remove() + @staticmethod + def _group_selector_label(group: PlanningGroupInfo) -> str: + role = "Pose" if bool(group["has_pose_target"]) else "Aux" + return f"{role}: {ViserPanelGui._group_display_name(group)}" + + @staticmethod + def _group_display_name(group: PlanningGroupInfo) -> str: + robot_name = str(group["robot_name"]) + group_name = str(group["name"]) + return robot_name if group_name == "manipulator" else f"{robot_name} {group_name}" + def _set_group_selected(self, group_id: PlanningGroupID, selected: bool) -> None: current = list(self.state.selected_group_ids) if selected and group_id not in current: @@ -454,10 +423,8 @@ def _set_group_selected(self, group_id: PlanningGroupID, selected: bool) -> None elif not selected and group_id in current: current.remove(group_id) self.state.selected_group_ids = tuple(current) - self.state.auxiliary_group_ids = tuple( - group_id for group_id in self.state.auxiliary_group_ids if group_id in current - ) - self._sync_selected_robot_from_groups() + self._sync_group_selection_state() + self._prune_inactive_group_state() self._initialize_selected_group_targets() self.state.mark_plan_stale() self._build_joint_sliders() @@ -471,11 +438,23 @@ def _select_all_manipulators(self) -> None: self.state.selected_group_ids = tuple( manipulator_groups or [str(group["id"]) for group in groups] ) - self._sync_selected_robot_from_groups() + self._sync_group_selection_state() self._initialize_selected_group_targets() self._build_joint_sliders() self.refresh() + def _clear_group_selection(self) -> None: + if self._closed: + return + self.state.selected_group_ids = () + self._sync_group_selection_state() + self._prune_inactive_group_state() + self.state.target_status = TargetStatus.EMPTY + self.state.feasibility.status = FeasibilityStatus.UNKNOWN + self.state.plan_state = PanelPlanState() + self._build_joint_sliders() + self.refresh() + def _group_info_by_id(self) -> dict[PlanningGroupID, PlanningGroupInfo]: return {str(group["id"]): group for group in self.adapter.list_planning_groups()} @@ -486,6 +465,45 @@ def _sync_selected_robot_from_groups(self) -> None: ) self.state.selected_robot = None if first_group is None else str(first_group["robot_name"]) + def _sync_group_selection_state(self) -> None: + self._sync_selected_robot_from_groups() + self.state.auxiliary_group_ids = self._selected_auxiliary_group_ids() + + def _selected_pose_group_ids(self) -> tuple[PlanningGroupID, ...]: + groups = self._group_info_by_id() + return tuple( + group_id + for group_id in self.state.selected_group_ids + if (group := groups.get(group_id)) is not None and bool(group["has_pose_target"]) + ) + + def _selected_auxiliary_group_ids(self) -> tuple[PlanningGroupID, ...]: + groups = self._group_info_by_id() + return tuple( + group_id + for group_id in self.state.selected_group_ids + if (group := groups.get(group_id)) is not None and not bool(group["has_pose_target"]) + ) + + def _active_pose_targets(self) -> dict[PlanningGroupID, Pose]: + return { + group_id: self.state.pose_targets[group_id] + for group_id in self._selected_pose_group_ids() + if group_id in self.state.pose_targets + } + + def _prune_inactive_group_state(self) -> None: + selected = set(self.state.selected_group_ids) + for mapping in ( + self.state.pose_targets, + self.state.group_joint_targets, + self.state.group_poses, + self.state.group_diagnostics, + ): + for group_id in [group_id for group_id in mapping if group_id not in selected]: + mapping.pop(group_id, None) + self._refresh_target_joints_from_groups() + def _initialize_selected_group_targets(self) -> None: groups = self._group_info_by_id() for group_id in self.state.selected_group_ids: @@ -545,18 +563,21 @@ def _current_snapshot_by_group(self) -> dict[PlanningGroupID, list[float]]: def _sync_preset_dropdown(self) -> None: handle = self._handles.get("preset") - if handle is None or self.state.selected_robot is None: + if handle is None: return - info: RobotInfo | None = self.adapter.get_robot_info(self.state.selected_robot) - config = self.adapter.get_robot_config(self.state.selected_robot) + selected_robot_names = self._selected_robot_names() options = ["Select preset..."] - if (info is not None and info["init_joints"] is not None) or self.adapter.get_init_joints( - self.state.selected_robot - ) is not None: + if any( + self.adapter.get_init_joints(robot_name) is not None + for robot_name in selected_robot_names + ): options.append("Init") options.append("Current") - home_joints = config.home_joints if config is not None else None - if (info is not None and info["home_joints"] is not None) or home_joints is not None: + if any( + (config := self.adapter.get_robot_config(robot_name)) is not None + and config.home_joints is not None + for robot_name in selected_robot_names + ): options.append("Home") for attr in ("options", "values"): if hasattr(handle, attr): @@ -568,35 +589,16 @@ def _sync_preset_dropdown(self) -> None: def _apply_preset(self, preset: str) -> None: if self._closed: return - robot_name = self.state.selected_robot - if robot_name is None: - return - config = self.adapter.get_robot_config(robot_name) - if config is None: - return - if preset == "Current": - current = self.adapter.get_current_joint_state(robot_name) - values_by_name = ( - dict(zip(current.name, current.position, strict=False)) - if current is not None - else {} - ) - elif preset == "Init": - init = self.adapter.get_init_joints(robot_name) - values_by_name = ( - dict(zip(init.name, init.position, strict=False)) if init is not None else {} - ) - elif preset == "Home": - values_by_name = dict(zip(config.joint_names, config.home_joints or [], strict=False)) - else: + if preset not in {"Current", "Init", "Home"}: return groups = [ group for group in self.adapter.list_planning_groups() if group["id"] in self.state.selected_group_ids - and str(group["robot_name"]) == robot_name ] for group in groups: + robot_name = str(group["robot_name"]) + values_by_name = self._preset_values_by_name(preset, robot_name) global_names = [str(name) for name in group["joint_names"]] local_names = [str(name) for name in group["local_joint_names"]] values = [ @@ -608,6 +610,43 @@ def _apply_preset(self, preset: str) -> None: self._submit_joint_target_evaluation() self.refresh() + def _selected_robot_names(self) -> tuple[str, ...]: + groups = self._group_info_by_id() + names: list[str] = [] + for group_id in self.state.selected_group_ids: + group = groups.get(group_id) + if group is None: + continue + robot_name = str(group["robot_name"]) + if robot_name not in names: + names.append(robot_name) + return tuple(names) + + def _preset_values_by_name(self, preset: str, robot_name: str) -> dict[str, float]: + if preset == "Current": + current = self.adapter.get_current_joint_state(robot_name) + if current is None: + return {} + return { + str(name): float(value) + for name, value in zip(current.name, current.position, strict=False) + } + if preset == "Init": + init = self.adapter.get_init_joints(robot_name) + if init is None: + return {} + return { + str(name): float(value) + for name, value in zip(init.name, init.position, strict=False) + } + config = self.adapter.get_robot_config(robot_name) + if config is None: + return {} + return { + str(name): float(value) + for name, value in zip(config.joint_names, config.home_joints or [], strict=False) + } + def _set_slider_values(self, joint_names: list[str], values: list[float]) -> None: self._suppress_target_callbacks = True try: @@ -655,8 +694,8 @@ def _on_transform_update( sequence_id=sequence_id, source="cartesian", group_ids=self.state.selected_group_ids, - auxiliary_group_ids=self.state.auxiliary_group_ids, - pose_targets=dict(self.state.pose_targets), + auxiliary_group_ids=self._selected_auxiliary_group_ids(), + pose_targets=self._active_pose_targets(), check_collision=self.config.target_evaluation_check_collision, ) ) @@ -701,6 +740,24 @@ def _move_joint_target_visuals(self) -> None: ] self.scene.set_target_joints(str(robot_id), group["local_joint_names"], joints) + def _sync_target_ghost_visibility(self) -> None: + if self.scene is None: + return + active_robot_ids: set[str] = set() + groups = self._group_info_by_id() + for group_id in self._selected_pose_group_ids(): + group = groups.get(group_id) + if group is None: + continue + robot_id = self.adapter.robot_id_for_name(str(group["robot_name"])) + if robot_id is not None: + active_robot_ids.add(str(robot_id)) + set_target_active = getattr(self.scene, "set_target_active", None) + if not callable(set_target_active): + return + for _robot_name, robot_id, _config in self.adapter.robot_items(): + set_target_active(str(robot_id), str(robot_id) in active_robot_ids) + def _handle_target_evaluation_request( self, request: TargetEvaluationRequest ) -> TargetEvaluation | TargetSetEvaluation: @@ -775,11 +832,11 @@ def _sync_pose_targets_from_group_poses(self) -> None: group = groups.get(group_id) if group is None or not bool(group["has_pose_target"]): continue - if group_id in self.state.auxiliary_group_ids: + if group_id not in self._selected_pose_group_ids(): continue self.state.pose_targets[group_id] = pose updated_group_ids.append(group_id) - first_group_id = next(iter(self.state.selected_group_ids), None) + first_group_id = next(iter(self._selected_pose_group_ids()), None) if first_group_id is not None: self.state.cartesian_target = self.state.pose_targets.get(first_group_id) self._sync_scene_target_pose_controls(updated_group_ids) @@ -798,15 +855,10 @@ def _sync_scene_target_pose_controls(self, group_ids: list[PlanningGroupID]) -> def _update_status_text(self) -> None: current = self.state.current_joints + status_label = self.state.error or self.state.module_state status = [ - "### Manipulation Panel", - f"Robot: `{self.state.selected_robot or 'none'}`", - f"Module: `{self.state.module_state}`", - f"Backend: `{self.state.backend_status.value}`", - f"Target: `{self.state.target_status.value}`", - f"Feasibility: `{self.state.feasibility.status.value}`", - f"Plan: `{self.state.plan_state.status.value}`", - f"Action: `{self.state.action_status.value}`", + f"**Status:** {status_label}", + f"Target: `{self.state.target_status.value}` · Plan: `{self.state.plan_state.status.value}`", ] if self.state.selected_robot is not None: status.append( @@ -816,10 +868,35 @@ def _update_status_text(self) -> None: status.append(f"Current joints: `{[round(v, 3) for v in current]}`") if self.state.last_result: status.append(f"Last result: `{self.state.last_result}`") - if self.state.error: - status.append(f"Error: `{self.state.error}`") self._set_handle_value("status", "\n\n".join(status)) + def _update_target_summary(self) -> None: + primary_groups = self._selected_pose_group_ids() + auxiliary_groups = self._selected_auxiliary_group_ids() + ghost_groups = list(primary_groups) + lines = [ + f"Primary: `{self._summary_group_names(primary_groups)}`", + f"Auxiliary: `{self._summary_group_names(auxiliary_groups)}`", + f"Ghosts: `{self._summary_group_names(tuple(ghost_groups))}`", + f"Feasibility: `{self.state.feasibility.status.value}`", + ] + if not self.state.selected_group_ids: + lines = ["Select a planning group to define a target."] + elif not primary_groups and auxiliary_groups: + lines.append("Auxiliary-only selection: no pose target ghost will be shown.") + self._set_handle_value("target_summary", "\n\n".join(lines)) + + def _summary_group_names(self, group_ids: tuple[PlanningGroupID, ...]) -> list[str]: + groups = self._group_info_by_id() + names: list[str] = [] + for group_id in group_ids: + group = groups.get(group_id) + if group is None: + names.append(str(group_id)) + continue + names.append(self._group_display_name(group)) + return names + def _update_control_state(self) -> None: self._set_disabled("plan", not self.state.can_plan()) self._set_disabled("preview", not self.state.can_preview()) @@ -830,7 +907,9 @@ def _update_control_state(self) -> None: and self.state.can_execute(self.config.current_match_tolerance) ), ) - self._set_disabled("cancel", not self.state.can_cancel()) + can_cancel = self.state.can_cancel() + self._set_disabled("cancel", not can_cancel) + self._set_visible("cancel", can_cancel) self._update_target_visual_state() def _update_target_visual_state(self) -> None: @@ -1035,14 +1114,22 @@ def _set_error(self, message: str) -> None: def _set_handle_value(self, key: str, value: str) -> None: handle = self._handles.get(key) - if isinstance(handle, GuiMarkdownHandle): - self._set_optional_handle_attr(handle, "value", value) + if handle is None: + return + if hasattr(handle, "content") or hasattr(handle, "value"): + attr = "content" if hasattr(handle, "content") else "value" + self._set_optional_handle_attr(handle, attr, value) def _set_disabled(self, key: str, disabled: bool) -> None: handle = self._handles.get(key) if isinstance(handle, GuiButtonHandle): self._set_optional_handle_attr(handle, "disabled", disabled) + def _set_visible(self, key: str, visible: bool) -> None: + handle = self._handles.get(key) + if handle is not None: + self._set_optional_handle_attr(handle, "visible", visible) + @staticmethod def _set_optional_handle_attr(handle: object, attr: str, value: object) -> None: setattr(handle, attr, value) diff --git a/dimos/manipulation/visualization/viser/scene.py b/dimos/manipulation/visualization/viser/scene.py index fdfcc7fa56..54a544d3c3 100644 --- a/dimos/manipulation/visualization/viser/scene.py +++ b/dimos/manipulation/visualization/viser/scene.py @@ -93,6 +93,7 @@ def __init__( self._grid_handle: GridHandle | None = None self._grid_visible = True self._preview_visible: dict[str, bool] = {} + self._target_active: dict[str, bool] = {} self._target_tracks_current: dict[str, bool] = {} self._ensure_reference_grid() @@ -108,9 +109,17 @@ def set_reference_grid_visible(self, visible: bool) -> None: def register_robot(self, robot_id: str, config: RobotModelConfig) -> None: self._configs_by_id[robot_id] = config self._preview_visible.setdefault(robot_id, False) + self._target_active.setdefault(robot_id, False) self._target_tracks_current.setdefault(robot_id, True) self._ensure_robot_urdfs(robot_id, config) + def set_target_active(self, robot_id: str, active: bool) -> None: + """Show target ghost only when at least one group on the robot is active.""" + self._target_active[robot_id] = active + if not active: + self._target_tracks_current[robot_id] = True + self._set_target_visibility(robot_id, active) + def _ensure_reference_grid(self) -> None: try: scene = self.server.scene @@ -169,7 +178,7 @@ def update_current_robot(self, robot_id: str, joint_state: JointState | None) -> self.set_urdf_joints(current, config.joint_names, joint_state.position) if self._target_tracks_current.get(robot_id, True): self._set_target_joints(robot_id, config.joint_names, joint_state.position) - self._set_target_visibility(robot_id, True) + self._set_target_visibility(robot_id, self._target_active.get(robot_id, False)) def show_preview(self, robot_id: str) -> None: """Show the transient preview-animation ghost. @@ -202,6 +211,7 @@ def set_target_joints( target = self._urdfs.get(f"{robot_id}:target") if target is None: return False + self._target_active[robot_id] = True self._target_tracks_current[robot_id] = False self._set_target_joints(robot_id, joint_names, joints) self._set_target_visibility(robot_id, True) @@ -263,6 +273,7 @@ def close(self) -> None: self._root_frames.clear() self._configs_by_id.clear() self._preview_visible.clear() + self._target_active.clear() self._target_tracks_current.clear() def _ensure_robot_urdfs(self, robot_id: str, config: RobotModelConfig) -> None: @@ -288,7 +299,9 @@ def _ensure_robot_urdfs(self, robot_id: str, config: RobotModelConfig) -> None: self._set_urdf_mesh_material( self._urdfs[key], GOAL_ROBOT_FEASIBLE_COLOR, GOAL_ROBOT_FEASIBLE_OPACITY ) - self._set_handle_visibility(self._urdfs[key], True) + self._set_handle_visibility( + self._urdfs[key], self._target_active.get(robot_id, False) + ) elif kind == "preview": self._set_urdf_mesh_material( self._urdfs[key], PREVIEW_ROBOT_COLOR, PREVIEW_ROBOT_OPACITY diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index 4b0f0872ed..fc3be64f18 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -360,18 +360,22 @@ def make_robot_config(**overrides: RobotConfigOverride) -> RobotConfigStub: def make_planning_group_info( - robot_name: str, config: RobotConfigStub | SimpleNamespace + robot_name: str, + config: RobotConfigStub | SimpleNamespace, + *, + group_name: str = "manipulator", + has_pose_target: bool = True, ) -> dict[str, object]: joint_names = [str(name) for name in config.joint_names] return { - "id": f"{robot_name}:manipulator", - "name": "manipulator", + "id": f"{robot_name}:{group_name}", + "name": group_name, "robot_name": robot_name, "joint_names": [f"{robot_name}/{name}" for name in joint_names], "local_joint_names": joint_names, "base_link": str(config.base_link), - "tip_link": str(config.end_effector_link), - "has_pose_target": True, + "tip_link": str(config.end_effector_link) if has_pose_target else None, + "has_pose_target": has_pose_target, "source": "fallback", } @@ -408,11 +412,18 @@ def get_robot_info(self, robot_name: str) -> RobotInfo | None: return None init = self.get_init_joints(robot_name) home_joints = config.home_joints if hasattr(config, "home_joints") else None + planning_groups = getattr(self, "_planning_groups", None) + if planning_groups is None: + planning_groups = [make_planning_group_info(robot_name, config)] + else: + planning_groups = [ + group for group in planning_groups if str(group["robot_name"]) == robot_name + ] return { "name": config.name, "world_robot_id": self.robot_id_for_name(robot_name) or robot_name, "joint_names": list(config.joint_names), - "planning_groups": [make_planning_group_info(robot_name, config)], + "planning_groups": planning_groups, "end_effector_link": config.end_effector_link, "base_link": config.base_link, "max_velocity": 1.0, @@ -579,8 +590,20 @@ def test_gui_builds_controls_in_manipulation_panel_folder( assert server.folders[0].label == "Manipulation Panel" assert server.folders[0].kwargs == {"expand_by_default": True} assert "status" in gui._handles - assert "robot" in gui._handles + assert "robot" not in gui._handles + assert "planning_groups_heading" in gui._handles + assert "target_heading" in gui._handles + assert "target_summary" in gui._handles + assert "actions_heading" in gui._handles assert "plan" in gui._handles + handle_order = list(gui._handles) + assert handle_order.index(f"group:{DEFAULT_GROUP_ID}") < handle_order.index("plan") + assert handle_order.index("target_summary") < handle_order.index("plan") + assert handle_order.index("plan") < handle_order.index("actions_folder") + assert isinstance(gui._handles["status"], GuiMarkdownHandle) + assert "Starting" not in gui._handles["status"].value + assert isinstance(gui._handles["target_summary"], GuiMarkdownHandle) + assert "Primary:" in gui._handles["target_summary"].value assert gui._operation_worker._timeout_seconds is None @@ -616,14 +639,11 @@ def test_gui_close_removes_handles_and_late_callbacks_are_noops( ) adapter = make_adapter_with_robot() gui = make_panel(server, adapter, ViserVisualizationConfig(), scene) - robot_dropdown = gui._handles["robot"] plan_button = server.buttons["Plan"] grid = grid_server.grids[0] handles = list(gui._handles.values()) gui.close() - if isinstance(robot_dropdown, GuiDropdownHandle) and robot_dropdown.update_callback is not None: - robot_dropdown.update_callback(SimpleNamespace(target=SimpleNamespace(value="arm"))) if plan_button.click_callback is not None: plan_button.click_callback(SimpleNamespace()) gui._set_scene_grid_visible(False) @@ -812,8 +832,10 @@ def test_preview_visibility_only_affects_preview_ghost_and_close_removes_handles scene.register_robot("robot1", config) target = scene._urdfs["robot1:target"] preview = scene._urdfs["robot1:preview"] - assert all(mesh.visible is True for mesh in target._meshes) + assert all(mesh.visible is False for mesh in target._meshes) assert all(mesh.visible is False for mesh in preview._meshes) + scene.set_target_active("robot1", True) + assert all(mesh.visible is True for mesh in target._meshes) scene.show_preview("robot1") assert all(mesh.visible is True for mesh in preview._meshes) assert all(mesh.visible is True for mesh in target._meshes) @@ -825,7 +847,7 @@ def test_preview_visibility_only_affects_preview_ghost_and_close_removes_handles assert all(mesh.visible is False for mesh in preview._meshes) -def test_target_ghost_is_visible_and_tracks_current_until_target_moves_it() -> None: +def test_target_ghost_tracks_current_but_is_visible_only_when_active() -> None: server = FakeServer() urdfs = [FakeViserUrdfWithMeshes(("joint1",)) for _ in range(3)] scene = ViserManipulationScene(server, lambda *args, **kwargs: urdfs.pop(0), preview_fps=10.0) @@ -843,12 +865,16 @@ def test_target_ghost_is_visible_and_tracks_current_until_target_moves_it() -> N target = scene._urdfs["robot1:target"] preview = scene._urdfs["robot1:preview"] - assert all(mesh.visible is True for mesh in target._meshes) + assert all(mesh.visible is False for mesh in target._meshes) assert all(mesh.visible is False for mesh in preview._meshes) scene.update_current_robot("robot1", FakeJointState(["joint1"], position=[0.25])) assert current.cfg == [0.25] assert target.cfg == [0.25] assert preview.cfg is None + assert all(mesh.visible is False for mesh in target._meshes) + + scene.set_target_active("robot1", True) + assert all(mesh.visible is True for mesh in target._meshes) scene.set_target_joints("robot1", ["joint1"], [0.8]) scene.update_current_robot("robot1", FakeJointState(["joint1"], position=[0.1])) @@ -856,6 +882,9 @@ def test_target_ghost_is_visible_and_tracks_current_until_target_moves_it() -> N assert target.cfg == [0.8] assert preview.cfg is None + scene.set_target_active("robot1", False) + assert all(mesh.visible is False for mesh in target._meshes) + def test_scene_parents_urdfs_under_base_pose_frame() -> None: server = FakeServer() @@ -932,7 +961,7 @@ def test_preview_animation_uses_separate_colored_ghost_and_hides_after_playback( assert ok is True assert preview.cfg == [1.0] assert all(mesh.visible is False for mesh in preview._meshes) - assert all(mesh.visible is True for mesh in target._meshes) + assert all(mesh.visible is False for mesh in target._meshes) def test_scene_target_helpers_handle_missing_robot_and_pose() -> None: @@ -1137,7 +1166,7 @@ def test_scene_registers_goal_robot_coloring_and_updates_visibility() -> None: assert all(mesh.visible is True for mesh in preview._meshes) scene.hide_preview("robot1") assert all(mesh.visible is False for mesh in preview._meshes) - assert all(mesh.visible is True for mesh in target._meshes) + assert all(mesh.visible is False for mesh in target._meshes) def test_scene_transform_controls_update_pose_callback_and_visual_state() -> None: @@ -1266,6 +1295,96 @@ def test_gui_removes_pose_selector_when_group_is_deselected( assert control.removed is True +def test_gui_group_selector_derives_primary_and_auxiliary_groups( + make_panel: Callable[..., ViserPanelGui], +) -> None: + current = FakeJointState(["j1", "grip"], position=[0.25, 0.5]) + config = make_robot_config(joint_names=["j1", "grip"], home_joints=[0.0, 0.0]) + pose_group = make_planning_group_info("arm", config) + auxiliary_group = make_planning_group_info( + "arm", config, group_name="gripper", has_pose_target=False + ) + module = FakeManipulationModule( + _robots={"arm": ("robot-1", config, None)}, + _planning_groups=[pose_group, auxiliary_group], + ) + world_monitor = SimpleNamespace( + get_current_joint_state=lambda robot_id: current, + is_state_stale=lambda robot_id, max_age=1.0: False, + is_state_valid=lambda robot_id, joint_state: True, + get_ee_pose=lambda robot_id, joint_state=None: Pose( + {"position": [0.0, 0.0, 0.0], "orientation": [0.0, 0.0, 0.0, 1.0]} + ), + ) + adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + target_controls = [] + scene = SimpleNamespace( + has_reference_grid=lambda: False, + ensure_target_controls=lambda *args: target_controls.append(args) or object(), + remove_target_controls=lambda *args: None, + set_target_active=lambda *args: None, + set_target_joints=lambda *args: True, + set_target_pose=lambda *args: None, + set_target_visual_state=lambda *args: None, + ) + server = FakeGuiServer() + + gui = make_panel(server, adapter, ViserVisualizationConfig(panel_enabled=True), scene) + aux_label = "Aux: arm gripper" + assert "robot" not in gui._handles + assert server.checkboxes["Pose: arm"].value is True + assert server.checkboxes[aux_label].value is False + + server.checkboxes[aux_label].update_callback( + SimpleNamespace(target=SimpleNamespace(value=True)) + ) + + assert gui.state.selected_group_ids == ("arm:manipulator", "arm:gripper") + assert gui.state.auxiliary_group_ids == ("arm:gripper",) + assert [call[0] for call in target_controls] == ["arm:manipulator"] + + +def test_gui_target_ghost_visibility_follows_active_selected_groups( + make_panel: Callable[..., ViserPanelGui], +) -> None: + left_config = make_robot_config(name="left", joint_names=["j1"], home_joints=[0.0]) + right_config = make_robot_config(name="right", joint_names=["j1"], home_joints=[0.0]) + module = FakeManipulationModule( + _robots={ + "left": ("left-id", left_config, None), + "right": ("right-id", right_config, None), + } + ) + current = FakeJointState(["j1"], position=[0.0]) + world_monitor = SimpleNamespace( + get_current_joint_state=lambda robot_id: current, + is_state_stale=lambda robot_id, max_age=1.0: False, + is_state_valid=lambda robot_id, joint_state: True, + get_ee_pose=lambda robot_id, joint_state=None: Pose( + {"position": [0.0, 0.0, 0.0], "orientation": [0.0, 0.0, 0.0, 1.0]} + ), + ) + adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + active_updates = [] + scene = SimpleNamespace( + has_reference_grid=lambda: False, + ensure_target_controls=lambda *args: object(), + remove_target_controls=lambda *args: None, + set_target_active=lambda *args: active_updates.append(args), + set_target_joints=lambda *args: True, + set_target_pose=lambda *args: None, + set_target_visual_state=lambda *args: None, + ) + + gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) + + assert active_updates[-2:] == [("left-id", True), ("right-id", False)] + gui._set_group_selected("right:manipulator", True) + assert active_updates[-2:] == [("left-id", True), ("right-id", True)] + gui._set_group_selected("left:manipulator", False) + assert active_updates[-2:] == [("left-id", False), ("right-id", True)] + + def test_gui_preset_dropdown_and_controls_include_init_home_current_and_callbacks( make_panel: Callable[..., ViserPanelGui], ) -> None: From 04de196b35dc5208c67caeb7c5ed49903458b4e0 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 12:22:32 -0700 Subject: [PATCH 055/110] fix: synchronize viser group previews --- .../visualization/viser/animation.py | 25 ++++- .../manipulation/visualization/viser/scene.py | 67 +++++++++++-- .../viser/test_viser_visualization.py | 70 +++++++++++++ .../viser/test_visualizer_lifecycle.py | 97 ++++++++++++++++++- .../visualization/viser/visualizer.py | 36 ++++++- 5 files changed, 280 insertions(+), 15 deletions(-) diff --git a/dimos/manipulation/visualization/viser/animation.py b/dimos/manipulation/visualization/viser/animation.py index baab4812cd..03b4e3321e 100644 --- a/dimos/manipulation/visualization/viser/animation.py +++ b/dimos/manipulation/visualization/viser/animation.py @@ -15,11 +15,31 @@ from __future__ import annotations from collections.abc import Callable, Sequence +from dataclasses import dataclass import time +from dimos.manipulation.planning.spec.models import PlanningGroupID from dimos.msgs.sensor_msgs.JointState import JointState +@dataclass(frozen=True) +class PreviewTrack: + """One render track owned by one or more planning groups.""" + + robot_id: str + group_ids: tuple[PlanningGroupID, ...] + joint_names: tuple[str, ...] + path: tuple[JointState, ...] + + +@dataclass(frozen=True) +class GroupPreviewAnimation: + """Group-native preview transaction for a generated plan.""" + + group_ids: tuple[PlanningGroupID, ...] + tracks: tuple[PreviewTrack, ...] + + def interpolate_joint_path( path: Sequence[JointState], duration: float, fps: float ) -> list[list[float]]: @@ -92,7 +112,8 @@ def animate(self, path: Sequence[JointState], duration: float, fps: float) -> bo if not frames: return False step_delay = duration / max(len(frames) - 1, 1) if duration > 0.0 else 0.0 - for joints in frames: + for index, joints in enumerate(frames): self._set_joints(joints) - self._sleep(step_delay) + if index < len(frames) - 1: + self._sleep(step_delay) return True diff --git a/dimos/manipulation/visualization/viser/scene.py b/dimos/manipulation/visualization/viser/scene.py index 54a544d3c3..049ffdf659 100644 --- a/dimos/manipulation/visualization/viser/scene.py +++ b/dimos/manipulation/visualization/viser/scene.py @@ -16,11 +16,16 @@ from collections.abc import Callable, Sequence from pathlib import Path +import time from typing import Protocol, TypeAlias, cast from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake -from dimos.manipulation.visualization.viser.animation import PreviewAnimator +from dimos.manipulation.visualization.viser.animation import ( + GroupPreviewAnimation, + PreviewTrack, + sampled_joint_path_frames, +) from dimos.manipulation.visualization.viser.runtime import ( VISER_INSTALL_HINT, VISER_URDF_INSTALL_HINT, @@ -197,13 +202,63 @@ def animate_path(self, robot_id: str, path: Sequence[JointState], duration: floa config = self._configs_by_id.get(robot_id) if config is None: return False - self.show_preview(robot_id) + preview = GroupPreviewAnimation( + group_ids=(), + tracks=( + PreviewTrack( + robot_id=robot_id, + group_ids=(), + joint_names=tuple(config.joint_names), + path=tuple(path), + ), + ), + ) + return self.animate_preview(preview, duration) + + def animate_preview(self, preview: GroupPreviewAnimation, duration: float) -> bool: + """Animate all preview tracks with one shared group-native frame clock.""" + if not preview.tracks: + return False + frames_by_robot: dict[str, list[list[float]]] = {} + joint_names_by_robot: dict[str, tuple[str, ...]] = {} + for track in preview.tracks: + if track.robot_id not in self._configs_by_id: + return False + frames = sampled_joint_path_frames(track.path, duration, self.preview_fps) + if not frames: + return False + frames_by_robot[track.robot_id] = frames + joint_names_by_robot[track.robot_id] = track.joint_names + + frame_count = max(len(frames) for frames in frames_by_robot.values()) + if frame_count <= 0: + return False + step_delay = duration / max(frame_count - 1, 1) if duration > 0.0 else 0.0 + + robot_ids = tuple(frames_by_robot) + for robot_id in robot_ids: + self.show_preview(robot_id) try: - return PreviewAnimator( - lambda joints: self._set_preview_ghost_joints(robot_id, config.joint_names, joints) - ).animate(path, duration, self.preview_fps) + for frame_index in range(frame_count): + for robot_id in robot_ids: + frames = frames_by_robot[robot_id] + joints = self._frame_at_shared_index(frames, frame_index, frame_count) + self._set_preview_ghost_joints(robot_id, joint_names_by_robot[robot_id], joints) + if frame_index < frame_count - 1: + time.sleep(step_delay) + return True finally: - self.hide_preview(robot_id) + for robot_id in robot_ids: + self.hide_preview(robot_id) + + @staticmethod + def _frame_at_shared_index( + frames: Sequence[list[float]], frame_index: int, frame_count: int + ) -> list[float]: + if frame_count <= 1 or len(frames) == 1: + return frames[-1] + source_index = round(frame_index * (len(frames) - 1) / (frame_count - 1)) + return frames[source_index] def set_target_joints( self, robot_id: str, joint_names: Sequence[str], joints: Sequence[float] diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index fc3be64f18..5f79b62a78 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -26,9 +26,12 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") from dimos.manipulation.visualization.types import RobotInfo, TargetEvaluation, TargetSetEvaluation +from dimos.manipulation.visualization.viser import scene as scene_module from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.animation import ( + GroupPreviewAnimation, PreviewAnimator, + PreviewTrack, interpolate_joint_path, sampled_joint_path_frames, ) @@ -964,6 +967,73 @@ def test_preview_animation_uses_separate_colored_ghost_and_hides_after_playback( assert all(mesh.visible is False for mesh in target._meshes) +def test_group_preview_animation_updates_all_tracks_on_shared_frame_clock( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = FakeServer() + scene = ViserManipulationScene( + server, lambda *args, **kwargs: FakeUrdf(("joint1",)), preview_fps=10.0 + ) + scene.prepared_urdf_path = lambda config: "dummy.urdf" + config = SimpleNamespace( + name="arm", + model_path="/tmp/arm.urdf", + package_paths={}, + xacro_args={}, + auto_convert_meshes=False, + joint_names=["joint1"], + ) + scene.register_robot("left", config) + scene.register_robot("right", config) + updates: list[tuple[str, tuple[str, ...], tuple[float, ...]]] = [] + sleep_calls: list[float] = [] + + def record_preview_joints( + robot_id: str, joint_names: Sequence[str], joints: Sequence[float] + ) -> None: + updates.append((robot_id, tuple(joint_names), tuple(joints))) + + monkeypatch.setattr(scene, "_set_preview_ghost_joints", record_preview_joints) + monkeypatch.setattr(scene_module.time, "sleep", sleep_calls.append) + + ok = scene.animate_preview( + GroupPreviewAnimation( + group_ids=("left/arm", "right/arm"), + tracks=( + PreviewTrack( + robot_id="left", + group_ids=("left/arm",), + joint_names=("joint1",), + path=( + FakeJointState(["joint1"], position=[0.0]), + FakeJointState(["joint1"], position=[1.0]), + ), + ), + PreviewTrack( + robot_id="right", + group_ids=("right/arm",), + joint_names=("joint1",), + path=( + FakeJointState(["joint1"], position=[10.0]), + FakeJointState(["joint1"], position=[11.0]), + ), + ), + ), + ), + duration=0.0, + ) + + assert ok is True + assert updates == [ + ("left", ("joint1",), (0.0,)), + ("right", ("joint1",), (10.0,)), + ("left", ("joint1",), (1.0,)), + ("right", ("joint1",), (11.0,)), + ] + assert sleep_calls == [0.0] + assert scene._preview_visible == {"left": False, "right": False} + + def test_scene_target_helpers_handle_missing_robot_and_pose() -> None: server = FakeTransformServer() scene = ViserManipulationScene( diff --git a/dimos/manipulation/visualization/viser/test_visualizer_lifecycle.py b/dimos/manipulation/visualization/viser/test_visualizer_lifecycle.py index 18b6fd327f..bb7ffd8d47 100644 --- a/dimos/manipulation/visualization/viser/test_visualizer_lifecycle.py +++ b/dimos/manipulation/visualization/viser/test_visualizer_lifecycle.py @@ -29,6 +29,7 @@ visualizer as visualizer_module, ) from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter +from dimos.manipulation.visualization.viser.animation import GroupPreviewAnimation from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig from dimos.manipulation.visualization.viser.runtime import ViserRuntime from dimos.manipulation.visualization.viser.visualizer import ViserManipulationVisualizer @@ -408,10 +409,12 @@ def show_preview(self, robot_id: str) -> None: def hide_preview(self, robot_id: str) -> None: calls.append(("hide", robot_id)) - def animate_path(self, robot_id: str, path: list[JointState], duration: float) -> None: - assert path == [current] + def animate_preview(self, preview: GroupPreviewAnimation, duration: float) -> None: + assert preview.group_ids == ("arm/manipulator",) + assert len(preview.tracks) == 1 + assert preview.tracks[0].path == (current,) assert duration == 1.5 - calls.append(("animate", robot_id)) + calls.append(("animate", preview.tracks[0].robot_id)) def close(self) -> None: calls.append(("scene", "close")) @@ -419,7 +422,10 @@ def close(self) -> None: world_monitor = SimpleNamespace( get_current_joint_state=lambda _robot_id: current, planning_groups=SimpleNamespace( - select=lambda _group_ids: SimpleNamespace(robot_names=("arm",)) + select=lambda _group_ids: SimpleNamespace( + groups=(SimpleNamespace(id="arm/manipulator", robot_name="arm"),), + robot_names=("arm",), + ) ), ) robot_config = fake_robot_config("arm") @@ -459,3 +465,86 @@ def close(self) -> None: ("scene", "close"), ("runtime", "close"), ] + + +def test_visualizer_animates_multi_robot_plan_as_one_group_preview( + monkeypatch: pytest.MonkeyPatch, +) -> None: + previews: list[GroupPreviewAnimation] = [] + + class FakeRuntime: + url = "http://localhost:8095" + + def __init__(self, config: ViserVisualizationConfig) -> None: + self.config = config + + def start(self) -> FakeServer: + return FakeServer() + + def close(self) -> None: + pass + + class FakeScene: + def __init__( + self, + server: FakeServer, + viser_urdf: type[FakeViserUrdf], + *, + preview_fps: float, + ) -> None: + pass + + def animate_preview(self, preview: GroupPreviewAnimation, duration: float) -> None: + assert duration == 2.0 + previews.append(preview) + + def close(self) -> None: + pass + + groups = ( + SimpleNamespace(id="left/arm", robot_name="left"), + SimpleNamespace(id="right/arm", robot_name="right"), + ) + world_monitor = SimpleNamespace( + get_current_joint_state=lambda robot_id: JointState( + {"name": ["joint1"], "position": [0.0 if robot_id == "left-id" else 10.0]} + ), + planning_groups=SimpleNamespace( + select=lambda _group_ids: SimpleNamespace(groups=groups, robot_names=("left", "right")) + ), + ) + configs = {"left": fake_robot_config("left"), "right": fake_robot_config("right")} + manipulation_module = SimpleNamespace( + robot_id_for_name=lambda robot_name: f"{robot_name}-id" if robot_name in configs else None, + get_robot_config=lambda robot_name: configs.get(robot_name), + ) + monkeypatch.setattr(visualizer_module, "ViserRuntime", FakeRuntime) + monkeypatch.setattr(visualizer_module, "ViserUrdf", FakeViserUrdf) + monkeypatch.setattr(visualizer_module, "ViserManipulationScene", FakeScene) + visualizer = ViserManipulationVisualizer( + world_monitor=world_monitor, + manipulation_module=manipulation_module, + config=ViserVisualizationConfig(panel_enabled=False), + ) + + visualizer.animate_plan( + GeneratedPlan( + group_ids=("left/arm", "right/arm"), + path=[ + JointState(name=["left/joint1", "right/joint1"], position=[0.0, 10.0]), + JointState(name=["left/joint1", "right/joint1"], position=[1.0, 11.0]), + ], + status=PlanningStatus.SUCCESS, + ), + duration=2.0, + ) + + assert len(previews) == 1 + preview = previews[0] + assert preview.group_ids == ("left/arm", "right/arm") + assert [(track.robot_id, track.group_ids) for track in preview.tracks] == [ + ("left-id", ("left/arm",)), + ("right-id", ("right/arm",)), + ] + assert [tuple(point.position) for point in preview.tracks[0].path] == [(0.0,), (1.0,)] + assert [tuple(point.position) for point in preview.tracks[1].path] == [(10.0,), (11.0,)] diff --git a/dimos/manipulation/visualization/viser/visualizer.py b/dimos/manipulation/visualization/viser/visualizer.py index 4433b221ef..0ea7fb575b 100644 --- a/dimos/manipulation/visualization/viser/visualizer.py +++ b/dimos/manipulation/visualization/viser/visualizer.py @@ -20,6 +20,10 @@ from dimos.manipulation.planning.planning_identifiers import make_global_joint_name from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter +from dimos.manipulation.visualization.viser.animation import ( + GroupPreviewAnimation, + PreviewTrack, +) from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig from dimos.manipulation.visualization.viser.gui import ViserPanelGui from dimos.manipulation.visualization.viser.runtime import ( @@ -179,16 +183,42 @@ def animate_plan(self, plan: GeneratedPlan, duration: float = 3.0) -> None: self._ensure_started() if self._adapter is None or self._scene is None: return + preview = self._build_group_preview_animation(plan) + if preview is not None: + self._scene.animate_preview(preview, duration) + + def _build_group_preview_animation(self, plan: GeneratedPlan) -> GroupPreviewAnimation | None: + if self._adapter is None: + return None selection = self._world_monitor.planning_groups.select(plan.group_ids) + tracks: list[PreviewTrack] = [] for robot_name in selection.robot_names: robot_id = self._adapter.robot_id_for_name(robot_name) config = self._adapter.get_robot_config(robot_name) current = self._adapter.get_current_joint_state(robot_name) if robot_id is None or config is None or current is None: - continue + logger.warning( + "Cannot build group preview for robot '%s': missing id, config, or state", + robot_name, + ) + return None path = self._robot_path_for_plan(robot_name, config, current, plan) - if path: - self._scene.animate_path(str(robot_id), path, duration) + if not path: + logger.warning("Cannot project generated plan for robot '%s'", robot_name) + return None + tracks.append( + PreviewTrack( + robot_id=str(robot_id), + group_ids=tuple( + group.id for group in selection.groups if group.robot_name == robot_name + ), + joint_names=tuple(config.joint_names), + path=tuple(path), + ) + ) + if not tracks: + return None + return GroupPreviewAnimation(group_ids=plan.group_ids, tracks=tuple(tracks)) def _robot_ids_for_groups(self, group_ids: Sequence[PlanningGroupID]) -> list[str]: if self._adapter is None: From 7e91f9b0547b073714d188de2c211cba92422e42 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 14:00:55 -0700 Subject: [PATCH 056/110] fix: integrate dual xarm6 execution --- AGENTS.md | 2 +- dimos/control/README.md | 1 - dimos/control/blueprints/dual.py | 18 +---- dimos/control/blueprints/test_dual.py | 69 +++++++++++++++++++ .../tasks/trajectory_task/trajectory_task.py | 17 +++++ dimos/control/test_control.py | 45 ++++++++++++ dimos/control/tick_loop.py | 8 ++- dimos/e2e_tests/test_control_coordinator.py | 23 ++++--- dimos/manipulation/blueprints.py | 40 ++++++++--- .../control/coordinator_client.py | 4 +- dimos/manipulation/manipulation_module.py | 59 +++++++++++++++- dimos/manipulation/planning/README.md | 2 +- dimos/manipulation/test_manipulation_unit.py | 42 +++++++++++ dimos/robot/all_blueprints.py | 3 +- dimos/robot/test_all_blueprints.py | 2 +- docs/capabilities/manipulation/readme.md | 5 +- 16 files changed, 286 insertions(+), 54 deletions(-) create mode 100644 dimos/control/blueprints/test_dual.py diff --git a/AGENTS.md b/AGENTS.md index 4da2b37404..b2b83d7d67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ dimos restart # stop + re-run with same original args | `xarm-perception-sim-agent` | xArm | sim | GPT-4o | — | Manipulation + perception + agent, sim | | `xarm7-planner-coordinator` | xArm7 | real | — | — | Trajectory planner coordinator | | `teleop-quest-xarm7` | xArm7 | real | — | — | Quest VR teleop | -| `dual-xarm6-planner` | xArm6×2 | real | — | — | Dual-arm motion planner | +| `dual-xarm6-planner-coordinator` | xArm6×2 | real | — | — | Dual-arm motion planner + coordinator | Run `dimos list` for the full list. diff --git a/dimos/control/README.md b/dimos/control/README.md index 858af0b6c4..56694a2c38 100644 --- a/dimos/control/README.md +++ b/dimos/control/README.md @@ -27,7 +27,6 @@ Centralized control system for multi-arm robots with per-joint arbitration. ```bash # Terminal 1: Run coordinator dimos run coordinator-mock # Single 7-DOF mock arm -dimos run coordinator-dual-mock # Dual arms (7+6 DOF) dimos run coordinator-piper-xarm # Real hardware # Terminal 2: Control via CLI diff --git a/dimos/control/blueprints/dual.py b/dimos/control/blueprints/dual.py index d721f88877..bed71132a4 100644 --- a/dimos/control/blueprints/dual.py +++ b/dimos/control/blueprints/dual.py @@ -15,7 +15,6 @@ """Dual-arm coordinator blueprints with trajectory control. Usage: - dimos run coordinator-dual-mock # Mock 7+6 DOF arms dimos run coordinator-dual-xarm # XArm7 left + XArm6 right dimos run coordinator-piper-xarm # XArm6 + Piper """ @@ -27,18 +26,6 @@ from dimos.robot.catalog.piper import piper as _catalog_piper from dimos.robot.catalog.ufactory import xarm6 as _catalog_xarm6, xarm7 as _catalog_xarm7 -# Dual mock arms (7-DOF left, 6-DOF right) -_mock_left = _catalog_xarm7(name="left_arm") -_mock_right = _catalog_xarm6(name="right_arm", add_gripper=False) - -coordinator_dual_mock = ControlCoordinator.blueprint( - hardware=[_mock_left.to_hardware_component(), _mock_right.to_hardware_component()], - tasks=[ - _mock_left.to_task_config(task_name="traj_left"), - _mock_right.to_task_config(task_name="traj_right"), - ], -) - # Dual XArm (XArm7 left, XArm6 right) _xarm7_left = _catalog_xarm7(name="left_arm", adapter_type="xarm", address=global_config.xarm7_ip) _xarm6_right = _catalog_xarm6( @@ -48,8 +35,8 @@ coordinator_dual_xarm = ControlCoordinator.blueprint( hardware=[_xarm7_left.to_hardware_component(), _xarm6_right.to_hardware_component()], tasks=[ - _xarm7_left.to_task_config(task_name="traj_left"), - _xarm6_right.to_task_config(task_name="traj_right"), + _xarm7_left.to_task_config(), + _xarm6_right.to_task_config(), ], ) @@ -71,7 +58,6 @@ __all__ = [ - "coordinator_dual_mock", "coordinator_dual_xarm", "coordinator_piper_xarm", ] diff --git a/dimos/control/blueprints/test_dual.py b/dimos/control/blueprints/test_dual.py new file mode 100644 index 0000000000..a61eca0080 --- /dev/null +++ b/dimos/control/blueprints/test_dual.py @@ -0,0 +1,69 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for dual-arm control blueprints.""" + +from dimos.control.blueprints.dual import coordinator_dual_xarm +from dimos.control.coordinator import ControlCoordinator +from dimos.manipulation.blueprints import dual_xarm6_planner_coordinator +from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.manipulation.planning.planning_identifiers import make_global_joint_names + + +def _coordinator_task_names(blueprint) -> list[str]: + atom = next(atom for atom in blueprint.blueprints if atom.module is ControlCoordinator) + return [task.name for task in atom.kwargs["tasks"]] + + +def _coordinator_tasks(blueprint): + atom = next(atom for atom in blueprint.blueprints if atom.module is ControlCoordinator) + return atom.kwargs["tasks"] + + +def _manipulation_robots(blueprint): + atom = next(atom for atom in blueprint.blueprints if atom.module is ManipulationModule) + return atom.kwargs["robots"] + + +def _manipulation_visualization(blueprint): + atom = next(atom for atom in blueprint.blueprints if atom.module is ManipulationModule) + return atom.kwargs["visualization"] + + +def test_dual_xarm6_integrated_blueprint_has_planner_and_coordinator() -> None: + modules = [atom.module for atom in dual_xarm6_planner_coordinator.blueprints] + + assert ManipulationModule in modules + assert ControlCoordinator in modules + + +def test_dual_xarm6_integrated_blueprint_uses_viser_for_execution_ui() -> None: + visualization = _manipulation_visualization(dual_xarm6_planner_coordinator) + + assert visualization == {"backend": "viser", "allow_plan_execute": True} + + +def test_dual_xarm6_integrated_tasks_match_planner_robots() -> None: + tasks_by_name = {task.name: task for task in _coordinator_tasks(dual_xarm6_planner_coordinator)} + + for robot in _manipulation_robots(dual_xarm6_planner_coordinator): + task = tasks_by_name[robot.coordinator_task_name] + assert task.joint_names == make_global_joint_names(robot.name, robot.joint_names) + + +def test_dual_coordinator_xarm_task_names_match_manipulation_robot_defaults() -> None: + assert _coordinator_task_names(coordinator_dual_xarm) == [ + "traj_left_arm", + "traj_right_arm", + ] diff --git a/dimos/control/tasks/trajectory_task/trajectory_task.py b/dimos/control/tasks/trajectory_task/trajectory_task.py index b23ef6db3f..f9ca8568cd 100644 --- a/dimos/control/tasks/trajectory_task/trajectory_task.py +++ b/dimos/control/tasks/trajectory_task/trajectory_task.py @@ -191,6 +191,23 @@ def execute(self, trajectory: JointTrajectory) -> bool: logger.warning(f"Empty trajectory for {self._name}") return False + if trajectory.joint_names and trajectory.joint_names != self._joint_names_list: + logger.warning( + f"Joint name mismatch for {self._name}: " + f"expected={self._joint_names_list}, received={trajectory.joint_names}" + ) + return False + + if not trajectory.joint_names: + expected_joint_count = len(self._joint_names_list) + for point in trajectory.points: + if len(point.positions) != expected_joint_count: + logger.warning( + f"Trajectory point dimension mismatch for {self._name}: " + f"expected={expected_joint_count}, received={len(point.positions)}" + ) + return False + # Preempt any active trajectory if self._state == TrajectoryState.EXECUTING: logger.info(f"Preempting active trajectory on {self._name}") diff --git a/dimos/control/test_control.py b/dimos/control/test_control.py index ae6bc1e9de..13f0579cb3 100644 --- a/dimos/control/test_control.py +++ b/dimos/control/test_control.py @@ -274,6 +274,29 @@ def test_execute_trajectory(self, trajectory_task, simple_trajectory): assert trajectory_task.is_active() assert trajectory_task.get_state() == TrajectoryState.EXECUTING + def test_execute_rejects_mismatched_joint_names(self, trajectory_task): + trajectory = JointTrajectory( + joint_names=["other/joint1", "other/joint2", "other/joint3"], + points=[ + TrajectoryPoint( + positions=[0.0, 0.0, 0.0], + velocities=[0.0, 0.0, 0.0], + time_from_start=0.0, + ), + TrajectoryPoint( + positions=[1.0, 0.5, 0.25], + velocities=[0.0, 0.0, 0.0], + time_from_start=1.0, + ), + ], + ) + + result = trajectory_task.execute(trajectory) + + assert result is False + assert not trajectory_task.is_active() + assert trajectory_task.get_state() == TrajectoryState.IDLE + def test_compute_during_trajectory(self, trajectory_task, simple_trajectory, coordinator_state): t_start = time.perf_counter() trajectory_task.execute(simple_trajectory) @@ -530,6 +553,28 @@ def test_tick_loop_calls_compute(self, mock_adapter): assert mock_task.compute.call_count > 0 + def test_write_all_hardware_logs_rejected_command(self, mocker): + hardware = {"arm": MagicMock()} + hardware["arm"].write_command.return_value = False + log_error = mocker.patch("dimos.control.tick_loop.logger.error") + tick_loop = TickLoop( + tick_rate=100.0, + hardware=hardware, + hardware_lock=threading.Lock(), + tasks={}, + task_lock=threading.Lock(), + joint_to_hardware={}, + ) + + tick_loop._write_all_hardware({"arm": ({"arm/joint1": 0.5}, ControlMode.SERVO_POSITION)}) + + hardware["arm"].write_command.assert_called_once_with( + {"arm/joint1": 0.5}, ControlMode.SERVO_POSITION + ) + log_error.assert_called_once_with( + "Hardware %s rejected %d %s command(s)", "arm", 1, "SERVO_POSITION" + ) + class TestIntegration: def test_full_trajectory_execution(self, mock_adapter): diff --git a/dimos/control/tick_loop.py b/dimos/control/tick_loop.py index d38b7e76d2..73eb7b6530 100644 --- a/dimos/control/tick_loop.py +++ b/dimos/control/tick_loop.py @@ -397,7 +397,13 @@ def _write_all_hardware( for hw_id, (positions, mode) in hw_commands.items(): if hw_id in self._hardware: try: - self._hardware[hw_id].write_command(positions, mode) + if not self._hardware[hw_id].write_command(positions, mode): + logger.error( + "Hardware %s rejected %d %s command(s)", + hw_id, + len(positions), + mode.name, + ) except Exception as e: logger.error(f"Failed to write to {hw_id}: {e}") diff --git a/dimos/e2e_tests/test_control_coordinator.py b/dimos/e2e_tests/test_control_coordinator.py index 40e4800b47..8d635805b8 100644 --- a/dimos/e2e_tests/test_control_coordinator.py +++ b/dimos/e2e_tests/test_control_coordinator.py @@ -201,8 +201,8 @@ def test_dual_arm_coordinator(self, lcm_spy, start_blueprint) -> None: """Test dual-arm coordinator with independent trajectories.""" lcm_spy.save_topic("/coordinator_joint_state#sensor_msgs.JointState") - # Start dual-arm mock coordinator - start_blueprint("coordinator-dual-mock") + # Start integrated dual-arm mock planner/coordinator + start_blueprint("dual-xarm6-planner-coordinator") lcm_spy.wait_for_saved_topic("/coordinator_joint_state#sensor_msgs.JointState") client = RPCClient(None, ControlCoordinator) @@ -213,15 +213,15 @@ def test_dual_arm_coordinator(self, lcm_spy, start_blueprint) -> None: assert "right_arm/joint1" in joints tasks = client.list_tasks() - assert "traj_left" in tasks - assert "traj_right" in tasks + assert "traj_left_arm" in tasks + assert "traj_right_arm" in tasks # Create trajectories for both arms left_trajectory = JointTrajectory( - joint_names=[f"left_arm/joint{i + 1}" for i in range(7)], + joint_names=[f"left_arm/joint{i + 1}" for i in range(6)], points=[ - TrajectoryPoint(time_from_start=0.0, positions=[0.0] * 7), - TrajectoryPoint(time_from_start=0.5, positions=[0.2] * 7), + TrajectoryPoint(time_from_start=0.0, positions=[0.0] * 6), + TrajectoryPoint(time_from_start=0.5, positions=[0.2] * 6), ], ) @@ -235,10 +235,11 @@ def test_dual_arm_coordinator(self, lcm_spy, start_blueprint) -> None: # Execute both via task_invoke assert ( - client.task_invoke("traj_left", "execute", {"trajectory": left_trajectory}) is True + client.task_invoke("traj_left_arm", "execute", {"trajectory": left_trajectory}) + is True ) assert ( - client.task_invoke("traj_right", "execute", {"trajectory": right_trajectory}) + client.task_invoke("traj_right_arm", "execute", {"trajectory": right_trajectory}) is True ) @@ -246,8 +247,8 @@ def test_dual_arm_coordinator(self, lcm_spy, start_blueprint) -> None: time.sleep(1.0) # Both should complete - left_state = client.task_invoke("traj_left", "get_state") - right_state = client.task_invoke("traj_right", "get_state") + left_state = client.task_invoke("traj_left_arm", "get_state") + right_state = client.task_invoke("traj_right_arm", "get_state") assert left_state == TrajectoryState.COMPLETED assert right_state == TrajectoryState.COMPLETED diff --git a/dimos/manipulation/blueprints.py b/dimos/manipulation/blueprints.py index 3714e53b6e..6c4b2764d3 100644 --- a/dimos/manipulation/blueprints.py +++ b/dimos/manipulation/blueprints.py @@ -63,8 +63,7 @@ ) -# Dual XArm6 planner with coordinator integration -# Usage: Start with coordinator_dual_mock, then plan/execute via RPC +# Dual XArm6 planner + coordinator with Viser execution UI. _left_arm_cfg = _catalog_xarm6( name="left_arm", adapter_type="xarm" if global_config.xarm6_ip else "mock", @@ -78,13 +77,32 @@ y_offset=-0.5, ) -dual_xarm6_planner = ManipulationModule.blueprint( - robots=[ - _left_arm_cfg.to_robot_model_config(), - _right_arm_cfg.to_robot_model_config(), - ], - planning_timeout=10.0, - visualization={"backend": "meshcat"}, +dual_xarm6_planner_coordinator = autoconnect( + ManipulationModule.blueprint( + robots=[ + _left_arm_cfg.to_robot_model_config(), + _right_arm_cfg.to_robot_model_config(), + ], + planning_timeout=10.0, + visualization={"backend": "viser", "allow_plan_execute": True}, + ), + ControlCoordinator.blueprint( + tick_rate=100.0, + publish_joint_state=True, + joint_state_frame_id="coordinator", + hardware=[ + _left_arm_cfg.to_hardware_component(), + _right_arm_cfg.to_hardware_component(), + ], + tasks=[ + _left_arm_cfg.to_task_config(), + _right_arm_cfg.to_task_config(), + ], + ), +).transports( + { + ("joint_state", JointState): LCMTransport("/coordinator/joint_state", JointState), + } ) @@ -135,7 +153,7 @@ _right_xarm7_cfg.to_robot_model_config(), ], planning_timeout=10.0, - enable_viz=True, + visualization={"backend": "viser", "allow_plan_execute": True}, ), ControlCoordinator.blueprint( tick_rate=100.0, @@ -362,7 +380,7 @@ __all__ = [ - "dual_xarm6_planner", + "dual_xarm6_planner_coordinator", "dual_xarm7_planner_coordinator", "xarm6_planner_only", "xarm7_planner_coordinator", diff --git a/dimos/manipulation/control/coordinator_client.py b/dimos/manipulation/control/coordinator_client.py index ed552e0846..6fe78d13cc 100644 --- a/dimos/manipulation/control/coordinator_client.py +++ b/dimos/manipulation/control/coordinator_client.py @@ -24,12 +24,10 @@ Usage: # Terminal 1: Start the coordinator dimos run coordinator-mock # Single arm - dimos run coordinator-dual-mock # Dual arm # Terminal 2: Run this client python -m dimos.manipulation.control.coordinator_client - python -m dimos.manipulation.control.coordinator_client --task traj_left - python -m dimos.manipulation.control.coordinator_client --task traj_right + python -m dimos.manipulation.control.coordinator_client --task traj_arm How it works: 1. Connects to ControlCoordinator via LCM RPC diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 654d9ce970..ffd221e983 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -125,6 +125,7 @@ class ManipulationModuleConfig(ModuleConfig): # to prevent the planner from routing trajectories below this height. # Set to None to disable. floor_z: float | None = None + coordinator_rpc_timeout: float = 3.0 class ManipulationModule(Module): @@ -630,7 +631,13 @@ def _plan_selected_path( if not result.is_success(): return self._fail(f"Planning failed: {result.status.name}") - logger.info("Path: %d waypoints", len(result.path)) + path_joints = list(result.path[-1].name) if result.path else [] + logger.info( + "Path: %d waypoints, groups=%s, joints=%s", + len(result.path), + group_ids, + path_joints, + ) self._store_generated_plan(group_ids, result) self._state = ManipulationState.COMPLETED return True @@ -1460,6 +1467,29 @@ def _get_coordinator_client(self) -> RPCClient | None: self._coordinator_client = RPCClient(None, ControlCoordinator) return self._coordinator_client + def _invoke_coordinator_task( + self, + client: RPCClient, + task_name: str, + method: str, + kwargs: dict[str, Any], + ) -> Any: + """Invoke a ControlCoordinator task with an execution-specific timeout.""" + remote_name = getattr(client, "remote_name", None) + rpc_client = getattr(client, "rpc", None) + call_sync = getattr(rpc_client, "call_sync", None) + if isinstance(remote_name, str) and callable(call_sync): + result, unsub_fn = call_sync( + f"{remote_name}/task_invoke", + ([task_name, method, kwargs], {}), + rpc_timeout=self.config.coordinator_rpc_timeout, + ) + unsub_fns = getattr(client, "_unsub_fns", None) + if isinstance(unsub_fns, list): + unsub_fns.append(unsub_fn) + return result + return client.task_invoke(task_name, method, kwargs) + @rpc def execute(self, robot_name: RobotName | None = None) -> bool: """Execute planned trajectory via ControlCoordinator.""" @@ -1482,6 +1512,12 @@ def execute_plan( affected = self._affected_robot_names(plan) except Exception as exc: return self._fail(f"Failed to resolve generated plan: {exc}") + logger.info( + "Execute plan: groups=%s, affected=%s, requested_robot=%s", + plan.group_ids, + affected, + robot_name, + ) robot_names = [robot_name] if robot_name is not None else affected assert self._world_monitor is not None @@ -1558,8 +1594,25 @@ def execute_plan( len(trajectory.points), trajectory.duration, ) - result = client.task_invoke( - config.coordinator_task_name, "execute", {"trajectory": trajectory} + try: + result = self._invoke_coordinator_task( + client, + config.coordinator_task_name, + "execute", + {"trajectory": trajectory}, + ) + except TimeoutError as exc: + return self._fail( + f"Coordinator RPC timed out for task '{config.coordinator_task_name}': {exc}" + ) + except Exception as exc: + return self._fail( + f"Coordinator RPC failed for task '{config.coordinator_task_name}': {exc}" + ) + logger.info( + "Coordinator execute result: task='%s', result=%r", + config.coordinator_task_name, + result, ) if not result: return self._fail("Coordinator rejected trajectory") diff --git a/dimos/manipulation/planning/README.md b/dimos/manipulation/planning/README.md index a1712411d7..96d8176268 100644 --- a/dimos/manipulation/planning/README.md +++ b/dimos/manipulation/planning/README.md @@ -139,7 +139,7 @@ accepted. |-----------|-------------| | `xarm6_planner_only` | XArm 6-DOF standalone (no coordinator) | | `xarm7-planner-coordinator` | XArm 7-DOF with coordinator | -| `dual-xarm6-planner` | Dual XArm 6-DOF | +| `dual-xarm6-planner-coordinator` | Dual XArm 6-DOF with coordinator | | `xarm-perception-sim` | XArm 7-DOF simulation perception stack | ## Directory Structure diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index a07ff1e5e2..b30dd9776a 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -483,6 +483,48 @@ def test_execute_rejected(self, robot_config, simple_trajectory): assert module.execute() is False assert module._state == ManipulationState.FAULT + def test_execute_times_out_when_coordinator_rpc_does_not_respond( + self, robot_config, simple_trajectory + ): + """Coordinator RPC timeout fails execution instead of hanging silently.""" + module = _make_module_with_monitor(robot_config) + module.config.coordinator_rpc_timeout = 0.01 + generator = MagicMock() + generator.generate.return_value = simple_trajectory + module._robots = {"test_arm": ("id", robot_config, generator)} + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("test_arm", "manipulator", ["joint1", "joint2", "joint3"])] + ) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["joint1", "joint2", "joint3"], position=[0.0, 0.0, 0.0] + ) + module._last_plan = GeneratedPlan( + group_ids=("test_arm/manipulator",), + path=[ + JointState( + name=["test_arm/joint1", "test_arm/joint2", "test_arm/joint3"], + position=[0.0, 0.0, 0.0], + ), + JointState( + name=["test_arm/joint1", "test_arm/joint2", "test_arm/joint3"], + position=[0.5, 0.5, 0.5], + ), + ], + status=PlanningStatus.SUCCESS, + ) + mock_client = MagicMock() + mock_client.remote_name = "ControlCoordinator" + mock_client._unsub_fns = [] + mock_client.rpc.call_sync.side_effect = TimeoutError("no response") + module._coordinator_client = mock_client + + assert module.execute() is False + + assert module._state == ManipulationState.FAULT + assert "timed out" in module._error_message + mock_client.rpc.call_sync.assert_called_once() + mock_client.task_invoke.assert_not_called() + def _make_module_with_monitor(*configs: RobotModelConfig) -> ManipulationModule: """Create a ManipulationModule with a mocked world monitor and robots configured.""" diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index aca17251c6..4e2f12eb7e 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -21,7 +21,6 @@ "coordinator-cartesian-ik-mock": "dimos.control.blueprints.teleop:coordinator_cartesian_ik_mock", "coordinator-cartesian-ik-piper": "dimos.control.blueprints.teleop:coordinator_cartesian_ik_piper", "coordinator-combined-xarm6": "dimos.control.blueprints.teleop:coordinator_combined_xarm6", - "coordinator-dual-mock": "dimos.control.blueprints.dual:coordinator_dual_mock", "coordinator-dual-xarm": "dimos.control.blueprints.dual:coordinator_dual_xarm", "coordinator-flowbase": "dimos.control.blueprints.mobile:coordinator_flowbase", "coordinator-flowbase-keyboard-teleop": "dimos.control.blueprints.mobile:coordinator_flowbase_keyboard_teleop", @@ -58,7 +57,7 @@ "desk-marker-tf": "dimos.perception.fiducial.blueprints.desk_marker_tf:desk_marker_tf", "drone-agentic": "dimos.robot.drone.blueprints.agentic.drone_agentic:drone_agentic", "drone-basic": "dimos.robot.drone.blueprints.basic.drone_basic:drone_basic", - "dual-xarm6-planner": "dimos.manipulation.blueprints:dual_xarm6_planner", + "dual-xarm6-planner-coordinator": "dimos.manipulation.blueprints:dual_xarm6_planner_coordinator", "dual-xarm7-planner-coordinator": "dimos.manipulation.blueprints:dual_xarm7_planner_coordinator", "keyboard-teleop-a750": "dimos.robot.manipulators.a750.blueprints:keyboard_teleop_a750", "keyboard-teleop-openarm": "dimos.robot.manipulators.openarm.blueprints:keyboard_teleop_openarm", diff --git a/dimos/robot/test_all_blueprints.py b/dimos/robot/test_all_blueprints.py index ac900e67ea..b0929ff1b6 100644 --- a/dimos/robot/test_all_blueprints.py +++ b/dimos/robot/test_all_blueprints.py @@ -49,7 +49,7 @@ "coordinator-velocity-xarm6", "coordinator-xarm6", "coordinator-xarm7", - "dual-xarm6-planner", + "dual-xarm6-planner-coordinator", "dual-xarm7-planner-coordinator", "teleop-hosted-go2", "teleop-hosted-xarm7", diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index bcbd550d0a..603f9e446e 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -166,8 +166,7 @@ uv run dimos run xarm7-planner-coordinator \ Dual-arm mock Viser example: ```bash -uv run dimos run dual-xarm6-planner \ - -o manipulationmodule.visualization.backend=viser +uv run dimos run dual-xarm6-planner-coordinator ``` External manipulation visualizers are initialized from a backend-neutral planning-scene snapshot @@ -221,7 +220,7 @@ visualization backend. | `keyboard-teleop-xarm7` | XArm7 7-DOF keyboard teleop with Drake viz | | `xarm6-planner-only` | XArm6 standalone planner (no coordinator) | | `xarm7-planner-coordinator` | XArm7 planner with coordinator integration | -| `dual-xarm6-planner` | Dual XArm6 planning | +| `dual-xarm6-planner-coordinator` | Dual XArm6 planning and execution with Viser | | `xarm-perception` | XArm7 + RealSense camera for perception | | `xarm-perception-agent` | XArm7 perception + LLM agent | | `xarm-perception-sim` | XArm7 simulation perception stack | From 4763fb980cf869269859ac560e239235f028cac1 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 15:47:45 -0700 Subject: [PATCH 057/110] fix: polish viser planning controls --- dimos/manipulation/visualization/viser/gui.py | 123 +++++++++--------- .../viser/test_viser_visualization.py | 74 ++++++++--- .../manipulation/visualization/viser/theme.py | 6 +- 3 files changed, 122 insertions(+), 81 deletions(-) diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 1c690f3fec..0398935faa 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -73,6 +73,9 @@ # Fallback joint-slider range (radians) when a robot config omits joint limits. DEFAULT_JOINT_LIMITS = (-3.14, 3.14) +PRIMARY_ACTION_COLOR = (0, 102, 179) +ACTIVE_GROUP_COLOR = PRIMARY_ACTION_COLOR +INACTIVE_GROUP_COLOR = (52, 52, 52) class ViserPanelGui: @@ -165,18 +168,12 @@ def _build(self) -> None: self._build_panel_controls(gui) def _build_panel_controls(self, gui: GuiApi) -> None: - self._handles["status"] = gui.add_markdown("**Status:** Ready") + self._handles["status"] = gui.add_markdown("### Status\n**State:** Ready") self._build_scene_controls(gui) self._handles["planning_groups_heading"] = gui.add_markdown( - "### Planning Groups\nChoose active target groups." + "### Planning Groups\nActive MoveIt group for pose goal, planning, and joint edits." ) self._sync_group_selector(self.adapter.list_planning_groups()) - select_all_button = gui.add_button("Select all") - select_all_button.on_click(lambda _: self._select_all_manipulators()) - self._handles["select_all_manipulators"] = select_all_button - clear_selection_button = gui.add_button("Clear selection") - clear_selection_button.on_click(lambda _: self._clear_group_selection()) - self._handles["clear_group_selection"] = clear_selection_button self._handles["target_heading"] = gui.add_markdown("### Target") preset_dropdown = gui.add_dropdown( "Preset", @@ -185,26 +182,28 @@ def _build_panel_controls(self, gui: GuiApi) -> None: ) preset_dropdown.on_update(lambda event: self._apply_preset(event.target.value)) self._handles["preset"] = preset_dropdown - self._handles["target_summary"] = gui.add_markdown("Select a group to define a target.") + self._handles["target_summary"] = gui.add_markdown( + f"Feasibility: `{self.state.feasibility.status.value}`" + ) self._handles["actions_heading"] = gui.add_markdown("### Actions") - plan_button = gui.add_button("Plan", disabled=True) + plan_button = gui.add_button("Plan", disabled=True, color=PRIMARY_ACTION_COLOR) plan_button.on_click(lambda _: self._submit_plan()) self._handles["plan"] = plan_button - more_actions = gui.add_folder("Plan controls", expand_by_default=False) - self._handles["actions_folder"] = more_actions - with more_actions: - preview_button = gui.add_button("Preview", disabled=True) - preview_button.on_click(lambda _: self._submit_preview()) - self._handles["preview"] = preview_button - execute_button = gui.add_button("Execute", disabled=True) - execute_button.on_click(lambda _: self._submit_execute()) - self._handles["execute"] = execute_button - cancel_button = gui.add_button("Cancel") - cancel_button.on_click(lambda _: self._submit_cancel()) - self._handles["cancel"] = cancel_button - clear_button = gui.add_button("Clear plan") - clear_button.on_click(lambda _: self._submit_clear()) - self._handles["clear"] = clear_button + self._handles["plan_controls_heading"] = gui.add_markdown("**Plan controls**") + preview_button = gui.add_button("Preview", disabled=True) + preview_button.on_click(lambda _: self._submit_preview()) + self._handles["preview"] = preview_button + execute_button = gui.add_button("Execute", disabled=True) + execute_button.on_click(lambda _: self._submit_execute()) + self._handles["execute"] = execute_button + cancel_button = gui.add_button("Cancel") + cancel_button.on_click(lambda _: self._submit_cancel()) + self._handles["cancel"] = cancel_button + clear_button = gui.add_button("Clear plan") + clear_button.on_click(lambda _: self._submit_clear()) + self._handles["clear"] = clear_button + joint_controls = gui.add_folder("Joint Control", expand_by_default=False) + self._handles["joint_control_folder"] = joint_controls self._build_joint_sliders() def _build_scene_controls(self, gui: GuiApi) -> None: @@ -283,6 +282,14 @@ def _build_joint_sliders(self) -> None: self._clear_joint_sliders() if not self.state.selected_group_ids: return + joint_folder = self._handles.get("joint_control_folder") + if joint_folder is not None: + with joint_folder: + self._build_joint_slider_handles(gui) + return + self._build_joint_slider_handles(gui) + + def _build_joint_slider_handles(self, gui: GuiApi) -> None: groups = self._group_info_by_id() target_by_name: dict[str, float] = {} if self.state.target_joints is not None: @@ -386,17 +393,21 @@ def _sync_group_selector(self, groups: list[PlanningGroupInfo]) -> None: key = f"group:{group_id}" seen_keys.add(key) handle = self._handles.get(key) - label = self._group_selector_label(group) + is_selected = group_id in selected + label = self._group_selector_label(group, selected=is_selected) if handle is None: - handle = self.server.gui.add_checkbox(label, initial_value=group_id in selected) - handle.on_update( - lambda event, gid=group_id: self._set_group_selected( - gid, bool(event.target.value) - ) + handle = self.server.gui.add_button( + label, + color=self._group_selector_color(is_selected), + hint="Click to toggle this planning group in the target set.", ) + handle.on_click(lambda _event, gid=group_id: self._toggle_group_selected(gid)) self._handles[key] = handle - elif hasattr(handle, "value"): - self._set_optional_handle_attr(handle, "value", group_id in selected) + else: + self._set_optional_handle_attr(handle, "label", label) + self._set_optional_handle_attr( + handle, "color", self._group_selector_color(is_selected) + ) for key in [key for key in self._handles if key.startswith("group:")]: if key not in seen_keys: @@ -406,9 +417,13 @@ def _sync_group_selector(self, groups: list[PlanningGroupInfo]) -> None: remove() @staticmethod - def _group_selector_label(group: PlanningGroupInfo) -> str: - role = "Pose" if bool(group["has_pose_target"]) else "Aux" - return f"{role}: {ViserPanelGui._group_display_name(group)}" + def _group_selector_label(group: PlanningGroupInfo, *, selected: bool = False) -> str: + _ = selected + return ViserPanelGui._group_display_name(group) + + @staticmethod + def _group_selector_color(selected: bool) -> tuple[int, int, int] | None: + return ACTIVE_GROUP_COLOR if selected else INACTIVE_GROUP_COLOR @staticmethod def _group_display_name(group: PlanningGroupInfo) -> str: @@ -430,6 +445,9 @@ def _set_group_selected(self, group_id: PlanningGroupID, selected: bool) -> None self._build_joint_sliders() self.refresh() + def _toggle_group_selected(self, group_id: PlanningGroupID) -> None: + self._set_group_selected(group_id, group_id not in self.state.selected_group_ids) + def _select_all_manipulators(self) -> None: groups = self.adapter.list_planning_groups() manipulator_groups = [ @@ -857,7 +875,8 @@ def _update_status_text(self) -> None: current = self.state.current_joints status_label = self.state.error or self.state.module_state status = [ - f"**Status:** {status_label}", + "### Status", + f"**State:** {status_label}", f"Target: `{self.state.target_status.value}` · Plan: `{self.state.plan_state.status.value}`", ] if self.state.selected_robot is not None: @@ -871,31 +890,9 @@ def _update_status_text(self) -> None: self._set_handle_value("status", "\n\n".join(status)) def _update_target_summary(self) -> None: - primary_groups = self._selected_pose_group_ids() - auxiliary_groups = self._selected_auxiliary_group_ids() - ghost_groups = list(primary_groups) - lines = [ - f"Primary: `{self._summary_group_names(primary_groups)}`", - f"Auxiliary: `{self._summary_group_names(auxiliary_groups)}`", - f"Ghosts: `{self._summary_group_names(tuple(ghost_groups))}`", - f"Feasibility: `{self.state.feasibility.status.value}`", - ] - if not self.state.selected_group_ids: - lines = ["Select a planning group to define a target."] - elif not primary_groups and auxiliary_groups: - lines.append("Auxiliary-only selection: no pose target ghost will be shown.") - self._set_handle_value("target_summary", "\n\n".join(lines)) - - def _summary_group_names(self, group_ids: tuple[PlanningGroupID, ...]) -> list[str]: - groups = self._group_info_by_id() - names: list[str] = [] - for group_id in group_ids: - group = groups.get(group_id) - if group is None: - names.append(str(group_id)) - continue - names.append(self._group_display_name(group)) - return names + self._set_handle_value( + "target_summary", f"Feasibility: `{self.state.feasibility.status.value}`" + ) def _update_control_state(self) -> None: self._set_disabled("plan", not self.state.can_plan()) @@ -1122,7 +1119,7 @@ def _set_handle_value(self, key: str, value: str) -> None: def _set_disabled(self, key: str, disabled: bool) -> None: handle = self._handles.get(key) - if isinstance(handle, GuiButtonHandle): + if handle is not None and hasattr(handle, "disabled"): self._set_optional_handle_attr(handle, "disabled", disabled) def _set_visible(self, key: str, visible: bool) -> None: diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index 5f79b62a78..7ae56ce380 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -36,7 +36,12 @@ sampled_joint_path_frames, ) from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig -from dimos.manipulation.visualization.viser.gui import ViserPanelGui +from dimos.manipulation.visualization.viser.gui import ( + ACTIVE_GROUP_COLOR, + INACTIVE_GROUP_COLOR, + PRIMARY_ACTION_COLOR, + ViserPanelGui, +) from dimos.manipulation.visualization.viser.scene import ViserManipulationScene from dimos.manipulation.visualization.viser.state import ( ActionStatus, @@ -127,6 +132,7 @@ def remove(self) -> None: class GuiButtonHandle: label: str disabled: bool = False + color: tuple[int, int, int] | None = None click_callback: GuiCallback | None = None removed: bool = False @@ -330,8 +336,16 @@ def add_dropdown( handle = GuiDropdownHandle(label=label, options=list(options), value=initial_value) return handle - def add_button(self, label: str, *, disabled: bool = False) -> GuiButtonHandle: - handle = GuiButtonHandle(label=label, disabled=disabled) + def add_button( + self, + label: str, + *, + disabled: bool = False, + color: tuple[int, int, int] | None = None, + hint: str | None = None, + ) -> GuiButtonHandle: + _ = hint + handle = GuiButtonHandle(label=label, disabled=disabled, color=color) self.buttons[label] = handle return handle @@ -599,14 +613,38 @@ def test_gui_builds_controls_in_manipulation_panel_folder( assert "target_summary" in gui._handles assert "actions_heading" in gui._handles assert "plan" in gui._handles + assert "select_all_manipulators" not in gui._handles + assert "clear_group_selection" not in gui._handles + assert "plan_controls_heading" in gui._handles + assert "actions_folder" not in gui._handles + assert "joint_control_folder" in gui._handles handle_order = list(gui._handles) assert handle_order.index(f"group:{DEFAULT_GROUP_ID}") < handle_order.index("plan") assert handle_order.index("target_summary") < handle_order.index("plan") - assert handle_order.index("plan") < handle_order.index("actions_folder") + assert handle_order.index("plan") < handle_order.index("plan_controls_heading") + assert handle_order.index("plan_controls_heading") < handle_order.index("preview") + assert handle_order.index("preview") < handle_order.index("execute") + assert handle_order.index("clear") < handle_order.index("joint_control_folder") assert isinstance(gui._handles["status"], GuiMarkdownHandle) assert "Starting" not in gui._handles["status"].value assert isinstance(gui._handles["target_summary"], GuiMarkdownHandle) - assert "Primary:" in gui._handles["target_summary"].value + assert "Feasibility:" in gui._handles["target_summary"].value + assert "Primary:" not in gui._handles["target_summary"].value + assert "Auxiliary:" not in gui._handles["target_summary"].value + assert "Ghosts:" not in gui._handles["target_summary"].value + assert isinstance(gui._handles["plan_controls_heading"], GuiMarkdownHandle) + assert "Plan controls" in gui._handles["plan_controls_heading"].value + plan_button = gui._handles["plan"] + assert isinstance(plan_button, GuiButtonHandle) + assert plan_button.color == PRIMARY_ACTION_COLOR + group_button = gui._handles[f"group:{DEFAULT_GROUP_ID}"] + assert isinstance(group_button, GuiButtonHandle) + assert group_button.label == "arm" + assert group_button.color == ACTIVE_GROUP_COLOR + joint_folder = gui._handles["joint_control_folder"] + assert isinstance(joint_folder, FakeFolder) + assert joint_folder.label == "Joint Control" + assert joint_folder.kwargs == {"expand_by_default": False} assert gui._operation_worker._timeout_seconds is None @@ -690,12 +728,12 @@ def test_dimos_theme_configures_supported_viser_chrome() -> None: assert apply_dimos_theme(server) is True assert server.theme_kwargs is not None - assert server.theme_kwargs["brand_color"] == (22, 130, 163) + assert server.theme_kwargs["brand_color"] == (0, 153, 255) assert server.theme_kwargs["dark_mode"] is True assert server.theme_kwargs["show_logo"] is False assert server.theme_kwargs["show_share_button"] is False - assert server.theme_kwargs["control_layout"] == "collapsible" - assert server.theme_kwargs["control_width"] == "medium" + assert server.theme_kwargs["control_layout"] == "fixed" + assert server.theme_kwargs["control_width"] == "large" def test_dimos_theme_configures_titlebar_when_supported(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1400,17 +1438,23 @@ def test_gui_group_selector_derives_primary_and_auxiliary_groups( server = FakeGuiServer() gui = make_panel(server, adapter, ViserVisualizationConfig(panel_enabled=True), scene) - aux_label = "Aux: arm gripper" assert "robot" not in gui._handles - assert server.checkboxes["Pose: arm"].value is True - assert server.checkboxes[aux_label].value is False - - server.checkboxes[aux_label].update_callback( - SimpleNamespace(target=SimpleNamespace(value=True)) - ) + pose_button = gui._handles["group:arm:manipulator"] + aux_button = gui._handles["group:arm:gripper"] + assert isinstance(pose_button, GuiButtonHandle) + assert isinstance(aux_button, GuiButtonHandle) + assert pose_button.label == "arm" + assert pose_button.color == ACTIVE_GROUP_COLOR + assert aux_button.label == "arm gripper" + assert aux_button.color == INACTIVE_GROUP_COLOR + + assert aux_button.click_callback is not None + aux_button.click_callback(SimpleNamespace(target=aux_button)) assert gui.state.selected_group_ids == ("arm:manipulator", "arm:gripper") assert gui.state.auxiliary_group_ids == ("arm:gripper",) + assert aux_button.label == "arm gripper" + assert aux_button.color == ACTIVE_GROUP_COLOR assert [call[0] for call in target_controls] == ["arm:manipulator"] diff --git a/dimos/manipulation/visualization/viser/theme.py b/dimos/manipulation/visualization/viser/theme.py index 339be04492..d38767d1da 100644 --- a/dimos/manipulation/visualization/viser/theme.py +++ b/dimos/manipulation/visualization/viser/theme.py @@ -36,7 +36,7 @@ DIMOS_THEME_TITLE = "DimOS Manipulation" DIMOS_THEME_URL = "https://github.com/dimensionalOS/dimos" -DIMOS_BRAND_COLOR = (22, 130, 163) +DIMOS_BRAND_COLOR = (0, 153, 255) DIMOS_LOGO_PATH = Path(__file__).with_name("assets") / "dimensional-logo.svg" logger = setup_logger() @@ -86,8 +86,8 @@ def _configure_theme(server: ViserServer, titlebar_content: TitlebarConfig | Non try: server.gui.configure_theme( titlebar_content=titlebar_content, - control_layout="collapsible", - control_width="medium", + control_layout="fixed", + control_width="large", dark_mode=True, show_logo=False, show_share_button=False, From 10e4d3173366675860d45481ee428d84868ed6e0 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 19:26:23 -0700 Subject: [PATCH 058/110] fix: address quick movegroup review comments --- dimos/control/blueprints/dual.py | 18 ++++++- dimos/manipulation/manipulation_module.py | 28 +++++----- .../planning/examples/manipulation_client.py | 8 +-- .../manipulation/planning/groups/__init__.py | 53 ------------------- .../planning/kinematics/jacobian_ik.py | 7 +-- .../planning/kinematics/pink_ik.py | 7 +-- .../kinematics/test_jacobian_ik_selection.py | 2 +- .../planning/kinematics/test_pink_ik.py | 2 +- .../planning/monitor/world_monitor.py | 2 +- .../planning/planners/rrt_planner.py | 2 +- .../planners/test_rrt_planner_selection.py | 2 +- dimos/manipulation/planning/spec/config.py | 7 ++- dimos/manipulation/planning/spec/protocols.py | 7 ++- .../planning/test_planning_group_utils.py | 3 +- .../planning/test_planning_groups.py | 2 +- .../planning/world/drake_world.py | 2 +- .../world/test_drake_world_planning_groups.py | 3 +- dimos/manipulation/test_manipulation_unit.py | 2 +- .../visualization/viser/adapter.py | 10 +--- .../visualization/viser/config.py | 7 --- dimos/manipulation/visualization/viser/gui.py | 6 +-- .../viser/test_operation_worker.py | 3 -- .../viser/test_viser_visualization.py | 2 +- dimos/robot/all_blueprints.py | 1 + dimos/robot/config.py | 2 +- 25 files changed, 65 insertions(+), 123 deletions(-) delete mode 100644 dimos/manipulation/planning/groups/__init__.py diff --git a/dimos/control/blueprints/dual.py b/dimos/control/blueprints/dual.py index 510108b1ea..3444f23335 100644 --- a/dimos/control/blueprints/dual.py +++ b/dimos/control/blueprints/dual.py @@ -15,16 +15,31 @@ """Dual-arm coordinator blueprints with trajectory control. Usage: + dimos run coordinator-dual-mock # Mock 7+6 DOF arms dimos run coordinator-dual-xarm # XArm7 left + XArm6 right dimos run coordinator-piper-xarm # XArm6 + Piper """ from __future__ import annotations -from dimos.control.blueprints._hardware import manipulator +from dimos.control.blueprints._hardware import manipulator, mock_arm from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.global_config import global_config +# Dual mock arms (7-DOF left, 6-DOF right) +_mock_left = mock_arm("left_arm", 7) +_mock_right = mock_arm("right_arm", 6) + +coordinator_dual_mock = ControlCoordinator.blueprint( + hardware=[_mock_left, _mock_right], + tasks=[ + TaskConfig(name="traj_left", type="trajectory", joint_names=_mock_left.joints, priority=10), + TaskConfig( + name="traj_right", type="trajectory", joint_names=_mock_right.joints, priority=10 + ), + ], +) + # Dual XArm (XArm7 left, XArm6 right) _xarm7_left = manipulator( "left_arm", @@ -86,6 +101,7 @@ __all__ = [ + "coordinator_dual_mock", "coordinator_dual_xarm", "coordinator_piper_xarm", ] diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index ffd221e983..fc21073f82 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -40,8 +40,8 @@ from dimos.core.module import Module, ModuleConfig from dimos.core.stream import In from dimos.manipulation.planning.factory import create_planning_specs, create_world -from dimos.manipulation.planning.groups import ( - PlanningGroup, +from dimos.manipulation.planning.groups.models import PlanningGroup +from dimos.manipulation.planning.groups.utils import ( joint_target_to_global_names, planning_group_id_from_selector, ) @@ -1491,15 +1491,16 @@ def _invoke_coordinator_task( return client.task_invoke(task_name, method, kwargs) @rpc - def execute(self, robot_name: RobotName | None = None) -> bool: + def execute(self) -> bool: """Execute planned trajectory via ControlCoordinator.""" - return self.execute_plan(self._last_plan, robot_name) + return self.execute_plan(self._last_plan) @rpc - def execute_plan( - self, plan: GeneratedPlan | None = None, robot_name: RobotName | None = None - ) -> bool: - """Project and execute a generated plan through affected trajectory tasks.""" + def execute_plan(self, plan: GeneratedPlan | None = None) -> bool: + """Project and execute a generated plan through affected trajectory tasks. + + TODO: proper time parametrization. + """ plan = plan or self._last_plan if plan is None or not plan.path: logger.warning("No generated plan") @@ -1513,19 +1514,14 @@ def execute_plan( except Exception as exc: return self._fail(f"Failed to resolve generated plan: {exc}") logger.info( - "Execute plan: groups=%s, affected=%s, requested_robot=%s", + "Execute plan: groups=%s, affected=%s", plan.group_ids, affected, - robot_name, ) - robot_names = [robot_name] if robot_name is not None else affected assert self._world_monitor is not None dispatches: list[tuple[RobotName, RobotModelConfig, JointTrajectory]] = [] - for name in robot_names: - if name not in affected: - logger.error("Generated plan does not affect robot '%s'", name) - return False + for name in affected: robot = self._get_robot(name) if robot is None: return False @@ -1864,7 +1860,7 @@ def _preview_execute_wait( self.preview_plan(duration=preview_duration, robot_name=robot_name) logger.info("Executing trajectory...") - if not self.execute(robot_name): + if not self.execute(): return SkillResult.fail("EXECUTION_FAILED", "Trajectory execution failed") if not self._wait_for_trajectory_completion(robot_name): diff --git a/dimos/manipulation/planning/examples/manipulation_client.py b/dimos/manipulation/planning/examples/manipulation_client.py index 335d549b85..57ba11b002 100644 --- a/dimos/manipulation/planning/examples/manipulation_client.py +++ b/dimos/manipulation/planning/examples/manipulation_client.py @@ -164,13 +164,13 @@ def preview( duration: float | None = None, robot_name: str | None = None, ) -> bool: - """Preview the last generated plan in Meshcat.""" + """Preview the last generated plan in Visualizer.""" return _client.preview_plan(None, duration, robot_name) -def execute(robot_name: str | None = None) -> bool: +def execute() -> bool: """Execute planned trajectory via coordinator.""" - return _client.execute(robot_name) + return _client.execute() def home(robot_name: str | None = None) -> bool: @@ -180,7 +180,7 @@ def home(robot_name: str | None = None) -> bool: home_joints = _client.get_robot_info(robot_name).get("home_joints", [0.0] * 7) success = _client.plan_to_joints(JointState(position=home_joints), robot_name) if success: - return _client.execute(robot_name) + return _client.execute() return False diff --git a/dimos/manipulation/planning/groups/__init__.py b/dimos/manipulation/planning/groups/__init__.py deleted file mode 100644 index ba142e5654..0000000000 --- a/dimos/manipulation/planning/groups/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Planning-group domain models, discovery, registry, and helpers.""" - -from dimos.manipulation.planning.groups.discovery import ( - FALLBACK_PLANNING_GROUP_NAME, - PlanningGroupDiscoveryError, - discover_planning_group_definitions, - generate_fallback_planning_group, - parse_srdf_planning_groups, -) -from dimos.manipulation.planning.groups.models import ( - PlanningGroup, - PlanningGroupDefinition, - PlanningGroupSelection, - PlanningGroupSource, -) -from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry -from dimos.manipulation.planning.groups.utils import ( - filter_joint_state_to_selected_joints, - joint_target_to_global_names, - matching_global_joint_name, - planning_group_id_from_selector, -) - -__all__ = [ - "FALLBACK_PLANNING_GROUP_NAME", - "PlanningGroup", - "PlanningGroupDefinition", - "PlanningGroupDiscoveryError", - "PlanningGroupRegistry", - "PlanningGroupSelection", - "PlanningGroupSource", - "discover_planning_group_definitions", - "filter_joint_state_to_selected_joints", - "generate_fallback_planning_group", - "joint_target_to_global_names", - "matching_global_joint_name", - "parse_srdf_planning_groups", - "planning_group_id_from_selector", -] diff --git a/dimos/manipulation/planning/kinematics/jacobian_ik.py b/dimos/manipulation/planning/kinematics/jacobian_ik.py index dc77a68d6b..574d7d2bee 100644 --- a/dimos/manipulation/planning/kinematics/jacobian_ik.py +++ b/dimos/manipulation/planning/kinematics/jacobian_ik.py @@ -30,11 +30,8 @@ import numpy as np -from dimos.manipulation.planning.groups import ( - PlanningGroup, - PlanningGroupSelection, - filter_joint_state_to_selected_joints, -) +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection +from dimos.manipulation.planning.groups.utils import filter_joint_state_to_selected_joints from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import ( IKResult, diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index 19b2a73a4d..bfb28b4639 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -25,11 +25,8 @@ import numpy as np -from dimos.manipulation.planning.groups import ( - PlanningGroup, - PlanningGroupSelection, - matching_global_joint_name, -) +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection +from dimos.manipulation.planning.groups.utils import matching_global_joint_name from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.planning_identifiers import make_global_joint_name from dimos.manipulation.planning.spec.config import RobotModelConfig diff --git a/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py b/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py index 8eedeb5d4d..a850b12aab 100644 --- a/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py +++ b/dimos/manipulation/planning/kinematics/test_jacobian_ik_selection.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import cast -from dimos.manipulation.planning.groups import PlanningGroup +from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.kinematics.jacobian_ik import JacobianIK from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus diff --git a/dimos/manipulation/planning/kinematics/test_pink_ik.py b/dimos/manipulation/planning/kinematics/test_pink_ik.py index 1ec9b7bd1b..fdc4728093 100644 --- a/dimos/manipulation/planning/kinematics/test_pink_ik.py +++ b/dimos/manipulation/planning/kinematics/test_pink_ik.py @@ -25,7 +25,7 @@ import pytest from dimos.manipulation.planning.factory import create_kinematics -from dimos.manipulation.planning.groups import PlanningGroup +from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.kinematics.pink_ik import ( PinkIK, diff --git a/dimos/manipulation/planning/monitor/world_monitor.py b/dimos/manipulation/planning/monitor/world_monitor.py index ca542030c9..cf0c605a3c 100644 --- a/dimos/manipulation/planning/monitor/world_monitor.py +++ b/dimos/manipulation/planning/monitor/world_monitor.py @@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any from dimos.constants import DEFAULT_THREAD_JOIN_TIMEOUT -from dimos.manipulation.planning.groups import PlanningGroupRegistry +from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry from dimos.manipulation.planning.monitor.robot_state_monitor import RobotStateMonitor from dimos.manipulation.planning.monitor.world_obstacle_monitor import WorldObstacleMonitor from dimos.manipulation.planning.spec.models import PlanningSceneInfo diff --git a/dimos/manipulation/planning/planners/rrt_planner.py b/dimos/manipulation/planning/planners/rrt_planner.py index 28f01f4b78..1e2d4fc396 100644 --- a/dimos/manipulation/planning/planners/rrt_planner.py +++ b/dimos/manipulation/planning/planners/rrt_planner.py @@ -26,7 +26,7 @@ import numpy as np -from dimos.manipulation.planning.groups import PlanningGroup, PlanningGroupSelection +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.planning_identifiers import ( local_joint_name_from_global, make_global_joint_names, diff --git a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py index 6d4dd20c9e..4436aba595 100644 --- a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py +++ b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py @@ -23,7 +23,7 @@ import numpy as np -from dimos.manipulation.planning.groups import PlanningGroup, PlanningGroupSelection +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.planners.rrt_planner import RRTConnectPlanner from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import PlanningStatus diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index b7d4fd4b50..cb795b2fbb 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -21,10 +21,8 @@ from pydantic import Field from dimos.core.module import ModuleConfig -from dimos.manipulation.planning.groups import ( - FALLBACK_PLANNING_GROUP_NAME, - PlanningGroupDefinition, -) +from dimos.manipulation.planning.groups.discovery import FALLBACK_PLANNING_GROUP_NAME +from dimos.manipulation.planning.groups.models import PlanningGroupDefinition from dimos.manipulation.planning.planning_identifiers import ( assert_local_joint_names, assert_valid_robot_name, @@ -50,6 +48,7 @@ class RobotModelConfig(ModuleConfig): group target frames instead. base_link: Compatibility robot-scoped base link used by current Drake weld/placement behavior. Planning groups own chain base links. + TODO: should remove package_paths: Dict mapping package names to filesystem Paths joint_limits_lower: Lower joint limits (radians) joint_limits_upper: Upper joint limits (radians) diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index 5f9ceb22fe..dfe7e9e427 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -29,7 +29,7 @@ import numpy as np from numpy.typing import NDArray - from dimos.manipulation.planning.groups import PlanningGroup, PlanningGroupSelection + from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.models import ( GeneratedPlan, @@ -168,7 +168,10 @@ def get_group_jacobian(self, ctx: Any, group_id: PlanningGroupID) -> NDArray[np. ... def get_ee_pose(self, ctx: Any, robot_id: WorldRobotID) -> PoseStamped: - """Get pose for a robot's unique pose-targetable planning group.""" + """Get pose for a robot's unique pose-targetable planning group. + + TODO: deprecate this. + """ ... def get_link_pose( diff --git a/dimos/manipulation/planning/test_planning_group_utils.py b/dimos/manipulation/planning/test_planning_group_utils.py index 1186372ae0..d479f34a4e 100644 --- a/dimos/manipulation/planning/test_planning_group_utils.py +++ b/dimos/manipulation/planning/test_planning_group_utils.py @@ -18,7 +18,8 @@ import pytest -from dimos.manipulation.planning.groups import PlanningGroup, joint_target_to_global_names +from dimos.manipulation.planning.groups.models import PlanningGroup +from dimos.manipulation.planning.groups.utils import joint_target_to_global_names from dimos.msgs.sensor_msgs.JointState import JointState diff --git a/dimos/manipulation/planning/test_planning_groups.py b/dimos/manipulation/planning/test_planning_groups.py index 303c469e67..dd95d1b961 100644 --- a/dimos/manipulation/planning/test_planning_groups.py +++ b/dimos/manipulation/planning/test_planning_groups.py @@ -20,7 +20,7 @@ import pytest -from dimos.manipulation.planning.groups import ( +from dimos.manipulation.planning.groups.discovery import ( FALLBACK_PLANNING_GROUP_NAME, PlanningGroupDiscoveryError, discover_planning_group_definitions, diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 5d70c0024f..23956c4a21 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -25,7 +25,7 @@ import numpy as np -from dimos.manipulation.planning.groups import PlanningGroupRegistry +from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry from dimos.manipulation.planning.planning_identifiers import ( assert_local_joint_names, make_global_joint_name, diff --git a/dimos/manipulation/planning/world/test_drake_world_planning_groups.py b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py index 5a1f8f1ecb..edaf5cacfe 100644 --- a/dimos/manipulation/planning/world/test_drake_world_planning_groups.py +++ b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py @@ -21,7 +21,8 @@ import numpy as np import pytest -from dimos.manipulation.planning.groups import PlanningGroupDefinition, PlanningGroupRegistry +from dimos.manipulation.planning.groups.models import PlanningGroupDefinition +from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.world.drake_world import DrakeWorld, _RobotData from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index b30dd9776a..3aeae3fd92 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -28,7 +28,7 @@ ManipulationModuleConfig, ManipulationState, ) -from dimos.manipulation.planning.groups import PlanningGroup, PlanningGroupSelection +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor from dimos.manipulation.planning.spec.config import RobotModelConfig diff --git a/dimos/manipulation/visualization/viser/adapter.py b/dimos/manipulation/visualization/viser/adapter.py index 0dec749d55..82acc1f63a 100644 --- a/dimos/manipulation/visualization/viser/adapter.py +++ b/dimos/manipulation/visualization/viser/adapter.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from dimos.manipulation.manipulation_module import ManipulationModule - from dimos.manipulation.planning.groups import PlanningGroup + from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName, WorldRobotID @@ -224,13 +224,7 @@ def plan_target_set(self, joint_targets: dict[PlanningGroupID, JointState]) -> b def preview_plan(self, robot_name: RobotName | None = None) -> bool: return self._module.preview_plan(robot_name=robot_name) - def preview_target_set_plan(self) -> bool: - return self._module.preview_plan() - - def execute(self, robot_name: RobotName | None = None) -> bool: - return self._module.execute(robot_name) - - def execute_target_set_plan(self) -> bool: + def execute(self) -> bool: return self._module.execute() def cancel(self) -> bool: diff --git a/dimos/manipulation/visualization/viser/config.py b/dimos/manipulation/visualization/viser/config.py index d77cc66a15..c7e66e4de1 100644 --- a/dimos/manipulation/visualization/viser/config.py +++ b/dimos/manipulation/visualization/viser/config.py @@ -54,13 +54,6 @@ class ViserVisualizationConfig(BaseModel): default=0.02, validation_alias=AliasChoices("current_match_tolerance", "viser_current_match_tolerance"), ) - target_evaluation_check_collision: bool = Field( - default=True, - validation_alias=AliasChoices( - "target_evaluation_check_collision", - "viser_target_evaluation_check_collision", - ), - ) allow_plan_execute: bool = False @property diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 0398935faa..5bb7a00d0c 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -714,7 +714,7 @@ def _on_transform_update( group_ids=self.state.selected_group_ids, auxiliary_group_ids=self._selected_auxiliary_group_ids(), pose_targets=self._active_pose_targets(), - check_collision=self.config.target_evaluation_check_collision, + check_collision=True, ) ) self.refresh() @@ -981,7 +981,7 @@ def operation() -> None: if not self._operation_is_current(operation_id): return self.state.action_status = ActionStatus.PREVIEWING - ok = self.adapter.preview_target_set_plan() + ok = self.adapter.preview_plan() self._finish_operation(f"preview={ok}", operation_id=operation_id) self._operation_worker.submit( @@ -1010,7 +1010,7 @@ def operation() -> None: return self.state.action_status = ActionStatus.EXECUTING self.state.plan_state.status = PlanStatus.EXECUTING - ok = self.adapter.execute_target_set_plan() + ok = self.adapter.execute() if not self._operation_is_current(operation_id): return if not ok: diff --git a/dimos/manipulation/visualization/viser/test_operation_worker.py b/dimos/manipulation/visualization/viser/test_operation_worker.py index 71a7857188..b6d0f4a7f5 100644 --- a/dimos/manipulation/visualization/viser/test_operation_worker.py +++ b/dimos/manipulation/visualization/viser/test_operation_worker.py @@ -144,9 +144,6 @@ def plan_to_joints(self, joints: JointState, robot_name: str | None = None) -> b def plan_target_set(self, joint_targets: dict[str, JointState]) -> bool: return True - def preview_target_set_plan(self) -> bool: - return True - def test_operation_worker_uses_per_operation_timeout() -> None: errors: list[str] = [] diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index 7ae56ce380..1c12ee0cb8 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -1751,7 +1751,7 @@ def test_gui_can_disable_collision_check_for_cartesian_target_evaluation( gui = make_panel( FakeGuiServer(), adapter, - ViserVisualizationConfig(panel_enabled=True, target_evaluation_check_collision=False), + ViserVisualizationConfig(panel_enabled=True), scene, ) request = TargetEvaluationRequest( diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 4e2f12eb7e..cc5797553d 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -21,6 +21,7 @@ "coordinator-cartesian-ik-mock": "dimos.control.blueprints.teleop:coordinator_cartesian_ik_mock", "coordinator-cartesian-ik-piper": "dimos.control.blueprints.teleop:coordinator_cartesian_ik_piper", "coordinator-combined-xarm6": "dimos.control.blueprints.teleop:coordinator_combined_xarm6", + "coordinator-dual-mock": "dimos.control.blueprints.dual:coordinator_dual_mock", "coordinator-dual-xarm": "dimos.control.blueprints.dual:coordinator_dual_xarm", "coordinator-flowbase": "dimos.control.blueprints.mobile:coordinator_flowbase", "coordinator-flowbase-keyboard-teleop": "dimos.control.blueprints.mobile:coordinator_flowbase_keyboard_teleop", diff --git a/dimos/robot/config.py b/dimos/robot/config.py index ca73cb384f..13a3aa83ee 100644 --- a/dimos/robot/config.py +++ b/dimos/robot/config.py @@ -28,7 +28,7 @@ from dimos.control.components import HardwareComponent, HardwareType from dimos.control.coordinator import TaskConfig -from dimos.manipulation.planning.groups import discover_planning_group_definitions +from dimos.manipulation.planning.groups.discovery import discover_planning_group_definitions from dimos.manipulation.planning.planning_identifiers import make_global_joint_names from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped From 4f771cef346532f07731859ecbd47e5e8d5e9417 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 00:28:55 -0700 Subject: [PATCH 059/110] feat: add composable movegroup planning primitives --- CONTEXT.md | 16 +- dimos/control/blueprints/test_dual.py | 2 +- dimos/manipulation/manipulation_module.py | 381 ++++++++++++------ .../planning/examples/manipulation_client.py | 4 +- .../planning/groups/identifiers.py | 126 ++++++ .../manipulation/planning/groups/registry.py | 4 +- dimos/manipulation/planning/groups/utils.py | 4 +- .../kinematics/drake_optimization_ik.py | 8 +- .../planning/kinematics/jacobian_ik.py | 9 - .../planning/kinematics/pink_ik.py | 152 +++---- .../planning/kinematics/test_pink_ik.py | 39 +- .../planning/monitor/test_world_monitor.py | 130 +++++- .../planning/monitor/world_monitor.py | 122 +++++- .../planning/planners/rrt_planner.py | 4 +- .../planning/planning_identifiers.py | 114 +----- dimos/manipulation/planning/spec/config.py | 4 +- dimos/manipulation/planning/spec/models.py | 37 +- dimos/manipulation/planning/spec/protocols.py | 6 +- .../manipulation/planning/utils/mesh_utils.py | 10 +- .../planning/world/drake_world.py | 4 +- dimos/manipulation/test_manipulation_unit.py | 137 +++++-- .../visualization/viser/visualizer.py | 2 +- dimos/robot/config.py | 2 +- 23 files changed, 882 insertions(+), 435 deletions(-) create mode 100644 dimos/manipulation/planning/groups/identifiers.py diff --git a/CONTEXT.md b/CONTEXT.md index eceab2a274..288e857608 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -25,8 +25,12 @@ The model-level declaration of a planning group before it is bound to a concrete _Avoid_: Runtime group, robot ID **End-Effector Association**: -Separate metadata used for pose-targeted operations. For a planning group defined by a chain, the end-effector link is the chain tip. For a planning group defined only by joints, there is no end-effector link. -_Avoid_: Planning group definition +Separate metadata used for pose-targeted operations. For a planning group defined by a chain, the end-effector link is the chain tip. For a planning group defined only by joints, there is no end-effector link. Documentation should use group end-effector language for this pose-target frame; operation names may use standard robotics terms such as forward kinematics and inverse kinematics when the planning-group scope is explicit in the parameters. +_Avoid_: Planning group definition, group target-frame pose + +**Group Forward Kinematics**: +A group-centric query that computes the end-effector pose for one pose-targetable planning group. Only that planning group's joints affect the result; other planning-world joints do not need to be supplied or filled. +_Avoid_: Planning-world FK, collision-state projection **Planning Group Selection**: The set of one or more planning groups chosen for a planning request. @@ -60,6 +64,14 @@ _Avoid_: Robot-scoped planner, groupless API A joint-name-keyed robot state that can represent any set of joints and is not inherently coupled to a robot, planning group, planning group selection, or joint-name scope. At flat multi-robot or coordinator boundaries, joint names are required and are global joint names. Robot identity and local-vs-global meaning are provided by the API boundary or containing type, not by extra fields on the generic joint state. _Avoid_: Planning-group-scoped state +**Collision Target Joint State**: +A partial joint state used as input to planning-world collision checking. Its joint names are required and must be global joint names. Unmentioned joints are filled from the current planning-world state before collision is checked on the resulting full world configuration. It has no planning-group semantics. +_Avoid_: Full state, group-scoped collision state + +**Current Planning-World Joint State**: +A flat snapshot of fresh monitored controllable joint positions for all robots in the planning world. Joint names are global joint names. It is the source used to fill unmentioned joints when applying a collision target joint state; fallback/default backend states are not current planning-world joint state. +_Avoid_: Internal state, selected-group state, default model state + **Robot Model Joint Names**: The ordered controllable joints of a robot model in the model's local namespace. This usually aligns with the model's actuated joints, but is not itself a planning group. _Avoid_: Implicit planning group diff --git a/dimos/control/blueprints/test_dual.py b/dimos/control/blueprints/test_dual.py index a61eca0080..4a788090b3 100644 --- a/dimos/control/blueprints/test_dual.py +++ b/dimos/control/blueprints/test_dual.py @@ -18,7 +18,7 @@ from dimos.control.coordinator import ControlCoordinator from dimos.manipulation.blueprints import dual_xarm6_planner_coordinator from dimos.manipulation.manipulation_module import ManipulationModule -from dimos.manipulation.planning.planning_identifiers import make_global_joint_names +from dimos.manipulation.planning.groups.identifiers import make_global_joint_names def _coordinator_task_names(blueprint) -> list[str]: diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index fc21073f82..bbbd876255 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -40,8 +40,14 @@ from dimos.core.module import Module, ModuleConfig from dimos.core.stream import In from dimos.manipulation.planning.factory import create_planning_specs, create_world +from dimos.manipulation.planning.groups.identifiers import ( + assert_global_joint_names, + assert_local_joint_names, + make_global_joint_names, +) from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.groups.utils import ( + filter_joint_state_to_selected_joints, joint_target_to_global_names, planning_group_id_from_selector, ) @@ -50,14 +56,11 @@ PinkKinematicsConfig, ) from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor -from dimos.manipulation.planning.planning_identifiers import ( - assert_global_joint_names, - assert_local_joint_names, - make_global_joint_names, -) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType from dimos.manipulation.planning.spec.models import ( + CollisionCheckResult, + ForwardKinematicsResult, GeneratedPlan, IKResult, Obstacle, @@ -549,7 +552,19 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint """Collect current state for exactly the selected global joints.""" assert self._world_monitor is not None selection = self._world_monitor.planning_groups.select(group_ids) + current = self._world_monitor.current_global_joint_state() + if isinstance(current, JointState): + try: + return filter_joint_state_to_selected_joints(current, selection.joint_names) + except ValueError as exc: + logger.error("Current state missing selected joints: %s", exc) + return None + if current is None: + logger.error("No fresh planning-world joint state") + return None + # Compatibility for older tests/mocks that provide only robot-local + # get_current_joint_state() behavior instead of the new primitive. robot_ids_by_name: dict[RobotName, WorldRobotID] = {} for robot_name in selection.robot_names: try: @@ -557,32 +572,26 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint except KeyError: logger.error("Robot '%s' is not registered", robot_name) return None - - current_by_robot: dict[RobotName, dict[str, float]] = {} - for robot_name, robot_id in robot_ids_by_name.items(): - current = self._world_monitor.get_current_joint_state(robot_id) - if current is None: - logger.error("No joint state for robot '%s'", robot_name) - return None - indexed_current = self._current_positions_by_name(robot_name, current) - if indexed_current is None: - return None - current_by_robot[robot_name] = indexed_current - names: list[str] = [] positions: list[float] = [] for group in selection.groups: - robot_state = current_by_robot[group.robot_name] - for resolved_name, local_name in zip( + current_local = self._world_monitor.get_current_joint_state( + robot_ids_by_name[group.robot_name] + ) + if current_local is None: + logger.error("No joint state for robot '%s'", group.robot_name) + return None + current_by_name = self._current_positions_by_name(group.robot_name, current_local) + if current_by_name is None: + return None + for global_name, local_name in zip( group.joint_names, group.local_joint_names, strict=True ): - if local_name not in robot_state: - logger.error("Current state missing selected joint '%s'", resolved_name) + if local_name not in current_by_name: + logger.error("Current state missing selected joint '%s'", global_name) return None - position = robot_state[local_name] - names.append(resolved_name) - positions.append(position) - + names.append(global_name) + positions.append(float(current_by_name[local_name])) return JointState(name=names, position=positions) def _joint_target_to_global_names( @@ -649,71 +658,218 @@ def _dismiss_preview(self, group_ids: Sequence[PlanningGroupID]) -> None: self._world_monitor.hide_preview(group_ids) self._world_monitor.publish_visualization() - def _solve_ik_for_pose( + @rpc + def check_collision( self, - robot_id: WorldRobotID, - pose: Pose, - seed: JointState, - check_collision: bool, - ) -> IKResult: - """Run the configured kinematics backend for a world-frame pose.""" - assert self._world_monitor and self._kinematics + target_joints: JointState, + max_age: float = 1.0, + ) -> CollisionCheckResult: + """Check a partial global joint target against the planning world.""" + if self._world_monitor is None: + return CollisionCheckResult( + status="UNAVAILABLE", + collision_free=None, + message="Planning is not initialized", + ) + return self._world_monitor.check_collision(target_joints, max_age=max_age) - target_pose = PoseStamped( - frame_id="world", - position=pose.position, - orientation=pose.orientation, + def list_planning_groups(self) -> list[PlanningGroup]: + """Return all planning groups in stable registry order.""" + if self._world_monitor is None: + return [] + return list(self._world_monitor.planning_groups.list()) + + def get_current_joint_state(self, robot_name: RobotName) -> JointState | None: + """Return the named robot's current local joint state with names.""" + if self._world_monitor is None: + return None + robot_id = self.robot_id_for_name(robot_name) + if robot_id is None: + return None + return self._world_monitor.get_current_joint_state(robot_id) + + @rpc + def forward_kinematics( + self, + group_id: PlanningGroupID, + target_joints: JointState | None = None, + max_age: float = 1.0, + ) -> ForwardKinematicsResult: + """Compute the selected planning group's end-effector pose.""" + if self._world_monitor is None: + return ForwardKinematicsResult( + status="UNAVAILABLE", + pose=None, + message="Planning is not initialized", + ) + try: + group = self._world_monitor.planning_groups.get(group_id) + except KeyError as exc: + return ForwardKinematicsResult(status="INVALID", pose=None, message=str(exc)) + if not group.has_pose_target: + return ForwardKinematicsResult( + status="INVALID", + pose=None, + message=f"Planning group '{group_id}' has no pose target frame", + ) + robot = self._robots.get(group.robot_name) + if robot is None: + return ForwardKinematicsResult( + status="INVALID", + pose=None, + message=f"Robot '{group.robot_name}' is not registered", + ) + robot_id, config, _ = robot + + if target_joints is None: + monitor = self._world_monitor.get_state_monitor(robot_id) + if monitor is None or monitor.is_state_stale(max_age): + return ForwardKinematicsResult( + status="STALE_STATE", + pose=None, + message="Fresh monitored robot joint state is unavailable", + ) + joint_state = self._world_monitor.get_current_joint_state(robot_id) + if joint_state is None: + return ForwardKinematicsResult( + status="STALE_STATE", + pose=None, + message="Fresh monitored robot joint state is unavailable", + ) + else: + if len(target_joints.name) != len(target_joints.position): + return ForwardKinematicsResult( + status="INVALID", + pose=None, + message="FK target name and position lengths must match", + ) + if len(set(target_joints.name)) != len(target_joints.name): + return ForwardKinematicsResult( + status="INVALID", + pose=None, + message="FK target contains duplicate joint names", + ) + try: + assert_global_joint_names(target_joints.name) + except ValueError as exc: + return ForwardKinematicsResult(status="INVALID", pose=None, message=str(exc)) + positions_by_global_name = dict( + zip(target_joints.name, target_joints.position, strict=True) + ) + missing = [name for name in group.joint_names if name not in positions_by_global_name] + if missing: + return ForwardKinematicsResult( + status="INVALID", + pose=None, + message=f"FK target missing group joints: {missing}", + ) + current = self._world_monitor.get_current_joint_state(robot_id) + current_by_local_name = ( + self._current_positions_by_name(group.robot_name, current) + if current is not None + else {} + ) + positions: list[float] = [] + for local_name, global_name in zip( + config.joint_names, + make_global_joint_names(group.robot_name, config.joint_names), + strict=True, + ): + if global_name in positions_by_global_name: + positions.append(float(positions_by_global_name[global_name])) + else: + positions.append(float((current_by_local_name or {}).get(local_name, 0.0))) + joint_state = JointState(name=list(config.joint_names), position=positions) + + try: + pose = self._world_monitor.get_group_pose(group_id, joint_state) + except Exception as exc: + return ForwardKinematicsResult( + status="UNAVAILABLE", + pose=None, + message=f"Forward kinematics failed: {exc}", + ) + return ForwardKinematicsResult( + status="VALID", pose=pose, message="Forward kinematics solved" ) - return self._kinematics.solve( + @rpc + def inverse_kinematics( + self, + pose_targets: Mapping[PlanningGroupID, PoseStamped], + auxiliary_group_ids: Sequence[PlanningGroupID] = (), + seed: JointState | None = None, + ) -> IKResult: + """Solve planning-group pose targets without collision filtering.""" + if self._kinematics is None or self._world_monitor is None: + return IKResult(status=IKStatus.NO_SOLUTION, message="Planning not initialized") + if not pose_targets: + return IKResult( + status=IKStatus.NO_SOLUTION, message="At least one pose target is required" + ) + group_ids = tuple(dict.fromkeys((*pose_targets.keys(), *auxiliary_group_ids))) + try: + target_groups = { + self._world_monitor.planning_groups.get(group_id): pose + for group_id, pose in pose_targets.items() + } + auxiliary_groups = tuple( + self._world_monitor.planning_groups.get(group_id) + for group_id in auxiliary_group_ids + ) + seed_state = seed or self._selected_joint_state(group_ids) + except (KeyError, ValueError) as exc: + return IKResult(status=IKStatus.NO_SOLUTION, message=str(exc)) + if seed_state is None: + return IKResult(status=IKStatus.NO_SOLUTION, message="No joint state") + return self._kinematics.solve_pose_targets( world=self._world_monitor.world, - robot_id=robot_id, - target_pose=target_pose, - seed=seed, - check_collision=check_collision, + pose_targets=target_groups, + auxiliary_groups=auxiliary_groups, + seed=seed_state, ) @rpc - def solve_ik( + def inverse_kinematics_single( self, pose: Pose, robot_name: RobotName | None = None, - check_collision: bool = True, seed: JointState | None = None, ) -> IKResult: - """Solve IK for a pose without planning a joint path. + """Solve IK for one robot's primary pose-targetable planning group. Args: pose: Target end-effector pose - robot_name: Robot to solve for (required if multiple robots configured) - check_collision: Whether to reject IK candidates in collision + robot_name: Robot to solve for (required if multiple robots configured). seed: Optional joint state to initialize local IK. Uses current state when omitted. """ - if self._kinematics is None or self._world_monitor is None: + if self._world_monitor is None: return IKResult(status=IKStatus.NO_SOLUTION, message="Planning not initialized") robot = self._get_robot(robot_name) if robot is None: return IKResult(status=IKStatus.NO_SOLUTION, message="Robot not found") + selected_robot_name, _, _, _ = robot + group_id = self._primary_pose_group_id_for_robot(selected_robot_name) + if group_id is None: + return IKResult( + status=IKStatus.NO_SOLUTION, message="No pose-targetable planning group" + ) + target_pose = PoseStamped( + frame_id="world", + position=pose.position, + orientation=pose.orientation, + ) + return self.inverse_kinematics({group_id: target_pose}, seed=seed) - with self._lock: - if self._state not in (ManipulationState.IDLE, ManipulationState.COMPLETED): - return IKResult( - status=IKStatus.NO_SOLUTION, - message=f"Cannot solve IK while state is {self._state.name}", - ) - self._state = ManipulationState.PLANNING - - _, robot_id, _, _ = robot - seed_state = seed or self._world_monitor.get_current_joint_state(robot_id) - if seed_state is None: - self._state = ManipulationState.IDLE - return IKResult(status=IKStatus.NO_SOLUTION, message="No joint state") - - result = self._solve_ik_for_pose(robot_id, pose, seed_state, check_collision) - self._state = ManipulationState.COMPLETED if result.is_success() else ManipulationState.IDLE - if result.is_success(): - logger.info(f"IK solved, error: {result.position_error:.4f}m") - return result + @rpc + def solve_ik( + self, + pose: Pose, + robot_name: RobotName | None = None, + seed: JointState | None = None, + ) -> IKResult: + """Compatibility wrapper for inverse_kinematics_single().""" + return self.inverse_kinematics_single(pose, robot_name=robot_name, seed=seed) @rpc def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: @@ -732,10 +888,10 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: group_id = self._default_group_id_for_robot(selected_robot_name) if group_id is None: return False - return self.plan_to_poses({group_id: pose}) + return self.plan_to_pose_targets({group_id: pose}) @rpc - def plan_to_poses( + def plan_to_pose_targets( self, pose_targets: Mapping[PlanningGroupID | PlanningGroup, Pose], auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup] = (), @@ -768,17 +924,10 @@ def plan_to_poses( if start is None: return self._fail("No joint state") - ik = self._kinematics.solve_pose_targets( - world=self._world_monitor.world, - pose_targets={ - self._world_monitor.planning_groups.get(group_id): pose - for group_id, pose in stamped_targets.items() - }, - auxiliary_groups=tuple( - self._world_monitor.planning_groups.get(group_id) for group_id in auxiliary_ids - ), + ik = self.inverse_kinematics( + pose_targets=stamped_targets, + auxiliary_group_ids=auxiliary_ids, seed=start, - check_collision=True, ) if not ik.is_success() or ik.joint_state is None: return self._fail(f"IK failed: {ik.status.name}") @@ -1037,30 +1186,20 @@ def evaluate_pose_target( "message": "Planning is not initialized or current state is unavailable", "collision_free": False, } - current = self._world_monitor.get_current_joint_state(robot_id) - if current is None: - return { - "success": False, - "joint_state": None, - "status": "UNAVAILABLE", - "message": "Planning is not initialized or current state is unavailable", - "collision_free": False, - } - ik = self._solve_ik_for_pose(robot_id, pose, current, check_collision=check_collision) + ik = self.inverse_kinematics_single(pose, robot_name=robot_name) joint_state = JointState(ik.joint_state) if ik.is_success() and ik.joint_state else None - collision_free = ( - bool(joint_state is not None) - if not check_collision - else bool( - joint_state is not None - and self._world_monitor.is_state_valid(robot_id, joint_state) + collision = self.check_collision(joint_state) if joint_state is not None else None + collision_free = bool( + joint_state is not None + and ( + not check_collision or (collision is not None and collision.collision_free is True) ) ) return { "success": joint_state is not None and collision_free, "joint_state": joint_state, "status": ik.status.name, - "message": ik.message, + "message": ik.message if collision is None else collision.message, "position_error": ik.position_error, "orientation_error": ik.orientation_error, "collision_free": collision_free, @@ -1215,31 +1354,26 @@ def _evaluate_global_target_set( "target_joints": None, } - collision_free = True + collision = self.check_collision(target_joints) if check_collision else None + collision_free = True if collision is None else collision.collision_free is True diagnostics: dict[PlanningGroupID, str] = {} selection = self._world_monitor.planning_groups.select(group_ids) - for robot_name, (robot_id, _, local_target) in local_targets.items(): - robot_collision_free = ( - self._world_monitor.is_state_valid(robot_id, local_target) - if check_collision - else True - ) - collision_free = collision_free and robot_collision_free - for group in selection.groups: - if group.robot_name == robot_name: - if not check_collision: - diagnostics[group.id] = "Target collision check skipped" - else: - diagnostics[group.id] = ( - "Target is collision-free" - if robot_collision_free - else "Target is in collision" - ) + for group in selection.groups: + if not check_collision: + diagnostics[group.id] = "Target collision check skipped" + else: + diagnostics[group.id] = ( + collision.message if collision is not None else "Target checked" + ) return { "success": collision_free, - "status": status if collision_free else "COLLISION", - "message": message if collision_free else "Target set is in collision", + "status": status + if collision_free + else (collision.status if collision is not None else "COLLISION"), + "message": message + if collision_free + else (collision.message if collision is not None else "Target set is in collision"), "collision_free": collision_free, "group_ids": group_ids, "target_joints": JointState(target_joints), @@ -1334,19 +1468,10 @@ def stamped_pose(pose: Pose | PoseStamped) -> PoseStamped: auxiliary_ids = tuple(planning_group_id_from_selector(group) for group in auxiliary_groups) group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_ids))) try: - target_groups = { - self._world_monitor.planning_groups.get(group_id): pose - for group_id, pose in stamped_targets.items() - } - auxiliary = tuple( - self._world_monitor.planning_groups.get(group_id) for group_id in auxiliary_ids - ) - ik = self._kinematics.solve_pose_targets( - world=self._world_monitor.world, - pose_targets=target_groups, - auxiliary_groups=auxiliary, + ik = self.inverse_kinematics( + pose_targets=stamped_targets, + auxiliary_group_ids=auxiliary_ids, seed=seed or self._selected_joint_state(group_ids), - check_collision=False, ) except (KeyError, ValueError) as exc: return { diff --git a/dimos/manipulation/planning/examples/manipulation_client.py b/dimos/manipulation/planning/examples/manipulation_client.py index 57ba11b002..f13515f582 100644 --- a/dimos/manipulation/planning/examples/manipulation_client.py +++ b/dimos/manipulation/planning/examples/manipulation_client.py @@ -124,7 +124,6 @@ def ik_pose( pitch: float | None = None, yaw: float | None = None, robot_name: str | None = None, - check_collision: bool = True, seed_joints: list[float] | JointState | None = None, ) -> IKResult: """Solve IK for a Cartesian pose without path planning. @@ -137,13 +136,12 @@ def ik_pose( pitch: Optional target pitch. Preserves current orientation if omitted. yaw: Optional target yaw. Preserves current orientation if omitted. robot_name: Robot to solve for when multiple robots are configured. - check_collision: Whether to reject IK candidates in collision. seed_joints: Optional initial joint configuration for local IK. Pass either a list of joint positions in robot joint order or a named JointState. """ target = _make_target_pose(x, y, z, roll, pitch, yaw, robot_name) seed = _make_seed_joint_state(seed_joints, robot_name) - return _client.solve_ik(target, robot_name, check_collision, seed) + return _client.inverse_kinematics_single(target, robot_name, seed) def plan_pose( diff --git a/dimos/manipulation/planning/groups/identifiers.py b/dimos/manipulation/planning/groups/identifiers.py new file mode 100644 index 0000000000..f6232d0eba --- /dev/null +++ b/dimos/manipulation/planning/groups/identifiers.py @@ -0,0 +1,126 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Planning-group and global-joint identifier helpers.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from dimos.manipulation.planning.spec.models import ( + GlobalJointName, + LocalModelJointName, + PlanningGroupID, + RobotName, +) + + +def assert_valid_robot_name(robot_name: RobotName) -> None: + """Validate a robot name for delimiter-based public IDs.""" + if not robot_name or "/" in robot_name: + raise ValueError(f"Invalid robot name: {robot_name!r}") + + +def assert_valid_local_joint_name(local_joint_name: LocalModelJointName) -> None: + """Validate a local model joint name for delimiter-based global joint names.""" + if not local_joint_name or "/" in local_joint_name: + raise ValueError(f"Invalid local joint name: {local_joint_name!r}") + + +def assert_local_joint_names(names: Sequence[LocalModelJointName]) -> None: + """Validate that names are local model joint names, not global joint names.""" + for name in names: + assert_valid_local_joint_name(name) + + +def make_planning_group_id(robot_name: RobotName, group_name: str) -> PlanningGroupID: + """Build a public planning group ID.""" + assert_valid_robot_name(robot_name) + if not group_name or "/" in group_name: + raise ValueError(f"Invalid planning group name: {group_name!r}") + return f"{robot_name}/{group_name}" + + +def parse_planning_group_id(group_id: PlanningGroupID) -> tuple[RobotName, str]: + """Split and validate a planning group ID.""" + parts = group_id.split("/", maxsplit=1) + if len(parts) != 2 or not parts[0] or not parts[1] or "/" in parts[1]: + raise ValueError( + f"Invalid planning group ID {group_id!r}; expected '{{robot_name}}/{{group_name}}'" + ) + return parts[0], parts[1] + + +def make_global_joint_name( + robot_name: RobotName, + local_joint_name: LocalModelJointName, +) -> GlobalJointName: + """Convert a local model joint name to a public global joint name.""" + assert_valid_robot_name(robot_name) + assert_valid_local_joint_name(local_joint_name) + return f"{robot_name}/{local_joint_name}" + + +def make_global_joint_names( + robot_name: RobotName, + local_joint_names: list[LocalModelJointName] | tuple[LocalModelJointName, ...], +) -> list[GlobalJointName]: + """Convert local model joint names to public global joint names.""" + return [make_global_joint_name(robot_name, name) for name in local_joint_names] + + +def is_global_joint_name(name: str) -> bool: + """Return whether name has the exact global joint-name shape.""" + parts = name.split("/") + return len(parts) == 2 and bool(parts[0]) and bool(parts[1]) + + +def assert_global_joint_names(names: Sequence[GlobalJointName]) -> None: + """Validate that names are global joint names.""" + invalid = [name for name in names if not is_global_joint_name(name)] + if invalid: + raise ValueError(f"Expected global joint names; got invalid names: {invalid}") + + +def local_joint_name_from_global( + robot_name: RobotName, + global_joint_name: GlobalJointName, +) -> LocalModelJointName: + """Validate and strip a global joint name for backend internals.""" + assert_valid_robot_name(robot_name) + prefix = f"{robot_name}/" + if not global_joint_name.startswith(prefix): + raise ValueError( + f"Global joint name {global_joint_name!r} does not belong to robot {robot_name!r}" + ) + local_name = global_joint_name[len(prefix) :] + try: + assert_valid_local_joint_name(local_name) + except ValueError as exc: + raise ValueError(f"Invalid global joint name: {global_joint_name!r}") from exc + return local_name + + +__all__ = [ + "assert_global_joint_names", + "assert_local_joint_names", + "assert_valid_local_joint_name", + "assert_valid_robot_name", + "is_global_joint_name", + "local_joint_name_from_global", + "make_global_joint_name", + "make_global_joint_names", + "make_planning_group_id", + "parse_planning_group_id", +] diff --git a/dimos/manipulation/planning/groups/registry.py b/dimos/manipulation/planning/groups/registry.py index 4006f38a13..608ac8c385 100644 --- a/dimos/manipulation/planning/groups/registry.py +++ b/dimos/manipulation/planning/groups/registry.py @@ -20,11 +20,11 @@ from typing import TYPE_CHECKING from dimos.manipulation.planning.groups.discovery import FALLBACK_PLANNING_GROUP_NAME -from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection -from dimos.manipulation.planning.planning_identifiers import ( +from dimos.manipulation.planning.groups.identifiers import ( make_global_joint_names, make_planning_group_id, ) +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName if TYPE_CHECKING: diff --git a/dimos/manipulation/planning/groups/utils.py b/dimos/manipulation/planning/groups/utils.py index 5c854ae60f..e55f997a33 100644 --- a/dimos/manipulation/planning/groups/utils.py +++ b/dimos/manipulation/planning/groups/utils.py @@ -16,12 +16,12 @@ from collections.abc import Mapping, Sequence -from dimos.manipulation.planning.groups.models import PlanningGroup -from dimos.manipulation.planning.planning_identifiers import ( +from dimos.manipulation.planning.groups.identifiers import ( assert_global_joint_names, assert_local_joint_names, is_global_joint_name, ) +from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.spec.models import ( GlobalJointName, LocalModelJointName, diff --git a/dimos/manipulation/planning/kinematics/drake_optimization_ik.py b/dimos/manipulation/planning/kinematics/drake_optimization_ik.py index abccee119d..76aa615066 100644 --- a/dimos/manipulation/planning/kinematics/drake_optimization_ik.py +++ b/dimos/manipulation/planning/kinematics/drake_optimization_ik.py @@ -76,10 +76,9 @@ def solve( seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, - check_collision: bool = True, max_attempts: int = 10, ) -> IKResult: - """Solve IK with multiple random restarts, returning the best collision-free solution.""" + """Solve IK with multiple random restarts, returning the best solution.""" error = self._validate_world(world) if error is not None: return error @@ -130,11 +129,6 @@ def solve( ) if result.is_success() and result.joint_state is not None: - # Check collision if requested - if check_collision: - if not world.check_config_collision_free(robot_id, result.joint_state): - continue # Try another seed - # Check error total_error = result.position_error + result.orientation_error if total_error < best_error: diff --git a/dimos/manipulation/planning/kinematics/jacobian_ik.py b/dimos/manipulation/planning/kinematics/jacobian_ik.py index 574d7d2bee..9988b2009c 100644 --- a/dimos/manipulation/planning/kinematics/jacobian_ik.py +++ b/dimos/manipulation/planning/kinematics/jacobian_ik.py @@ -118,7 +118,6 @@ def solve( seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, - check_collision: bool = True, max_attempts: int = 10, ) -> IKResult: """Solve IK with multiple random restarts. @@ -133,7 +132,6 @@ def solve( seed: Initial guess (uses current state if None) position_tolerance: Required position accuracy (meters) orientation_tolerance: Required orientation accuracy (radians) - check_collision: Whether to check collision of solution max_attempts: Maximum random restart attempts Returns: @@ -176,11 +174,6 @@ def solve( ) if result.is_success() and result.joint_state is not None: - # Check collision if requested - if check_collision: - if not world.check_config_collision_free(robot_id, result.joint_state): - continue # Try another seed - # Check error total_error = result.position_error + result.orientation_error if total_error < best_error: @@ -210,7 +203,6 @@ def solve_pose_targets( seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, - check_collision: bool = True, max_attempts: int = 10, ) -> IKResult: """Solve pose targets keyed by planning group with request-scoped auxiliaries. @@ -263,7 +255,6 @@ def solve_pose_targets( seed=full_seed, position_tolerance=position_tolerance, orientation_tolerance=orientation_tolerance, - check_collision=check_collision, max_attempts=max_attempts, ) if not result.is_success() or result.joint_state is None: diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index bfb28b4639..790a69f10e 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -18,17 +18,16 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass -import importlib from pathlib import Path -from types import ModuleType from typing import TYPE_CHECKING, Any import numpy as np +from dimos.manipulation.planning.groups.identifiers import make_global_joint_name from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection +from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry from dimos.manipulation.planning.groups.utils import matching_global_joint_name from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig -from dimos.manipulation.planning.planning_identifiers import make_global_joint_name from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import ( @@ -59,8 +58,8 @@ class PinkIKDependencyError(ImportError): @dataclass(frozen=True) class _PinkModules: - pink: ModuleType - pinocchio: ModuleType + pink: Any + pinocchio: Any _MANIPULATION_EXTRA_HINT = "Install manipulation dependencies with: uv sync --extra manipulation." @@ -120,61 +119,22 @@ def solve( seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, - check_collision: bool = True, max_attempts: int = 10, ) -> IKResult: """Solve IK with Pink, returning the standard planning ``IKResult``.""" - if not world.is_finalized: - return _failure(IKStatus.NO_SOLUTION, "World must be finalized before IK") - try: - robot_context = self._get_robot_context(world, robot_id) - except (FileNotFoundError, ImportError, ValueError) as exc: - return _failure(IKStatus.NO_SOLUTION, f"Pink IK model setup failed: {exc}") - - if seed is None: - with world.scratch_context() as ctx: - seed = world.get_joint_state(ctx, robot_id) - - lower_limits, upper_limits = world.get_joint_limits(robot_id) - target_model = self._target_in_model_frame(world.get_robot_config(robot_id), target_pose) - - fallback_result: IKResult | None = None - - for attempt in range(max_attempts): - try: - q0 = self._initial_q(robot_context, seed, lower_limits, upper_limits, attempt) - result = self._solve_single( - robot_context=robot_context, - target_model=target_model, - seed_q=q0, - lower_limits=lower_limits, - upper_limits=upper_limits, - position_tolerance=position_tolerance, - orientation_tolerance=orientation_tolerance, - ) - except ValueError as exc: - return _failure(IKStatus.NO_SOLUTION, f"Pink IK mapping failed: {exc}") - except Exception as exc: - return _failure(IKStatus.NO_SOLUTION, f"Pink IK solver failed: {exc}") - - if not result.is_success() or result.joint_state is None: - if fallback_result is None: - fallback_result = result - continue - - if check_collision and not world.check_config_collision_free( - robot_id, result.joint_state - ): - fallback_result = _collision_failure(result) - continue - - return result - - if fallback_result is not None: - return fallback_result - - return _failure(IKStatus.NO_SOLUTION, f"Pink IK failed after {max_attempts} attempts") + config = world.get_robot_config(robot_id) + group = _single_pose_group_for_robot(world, config.name) + except (KeyError, ValueError) as exc: + return _failure(IKStatus.NO_SOLUTION, str(exc)) + return self.solve_pose_targets( + world=world, + pose_targets={group: target_pose}, + seed=seed, + position_tolerance=position_tolerance, + orientation_tolerance=orientation_tolerance, + max_attempts=max_attempts, + ) def solve_pose_targets( self, @@ -184,7 +144,6 @@ def solve_pose_targets( seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, - check_collision: bool = True, max_attempts: int = 10, ) -> IKResult: """Solve planning-group pose targets and return selected global joints.""" @@ -225,7 +184,6 @@ def solve_pose_targets( seed=_seed_for_robot_with_world_fallback(world, robot_id, seed), position_tolerance=position_tolerance, orientation_tolerance=orientation_tolerance, - check_collision=check_collision, max_attempts=max_attempts, ) if not result.is_success() or result.joint_state is None: @@ -278,7 +236,6 @@ def _solve_pose_targets_for_robot( seed: JointState, position_tolerance: float, orientation_tolerance: float, - check_collision: bool, max_attempts: int, ) -> IKResult: """Solve one robot's one-or-more frame targets.""" @@ -330,11 +287,6 @@ def _solve_pose_targets_for_robot( if fallback_result is None: fallback_result = result continue - if check_collision and not world.check_config_collision_free( - robot_id, result.joint_state - ): - fallback_result = _collision_failure(result) - continue return result if fallback_result is not None: @@ -616,20 +568,27 @@ def _target_in_model_frame( def _load_optional_dependencies(solver: str) -> _PinkModules: - pink = _import_required_module( - "pink", - "Pink IK backend requires Pink. " - f"{_MANIPULATION_EXTRA_HINT} PyPI package: pin-pink; import name: pink.", - ) - pinocchio = _import_required_module( - "pinocchio", - f"Pink IK backend requires Pinocchio (import name 'pinocchio'). {_MANIPULATION_EXTRA_HINT}", - ) - qpsolvers = _import_required_module( - "qpsolvers", - "Pink IK backend requires qpsolvers plus a QP backend such as proxqp. " - f"{_MANIPULATION_EXTRA_HINT}", - ) + try: + import pink + except ImportError as exc: + raise PinkIKDependencyError( + "Pink IK backend requires Pink. " + f"{_MANIPULATION_EXTRA_HINT} PyPI package: pin-pink; import name: pink." + ) from exc + try: + import pinocchio + except ImportError as exc: + raise PinkIKDependencyError( + "Pink IK backend requires Pinocchio (import name 'pinocchio'). " + f"{_MANIPULATION_EXTRA_HINT}" + ) from exc + try: + import qpsolvers + except ImportError as exc: + raise PinkIKDependencyError( + "Pink IK backend requires qpsolvers plus a QP backend such as proxqp. " + f"{_MANIPULATION_EXTRA_HINT}" + ) from exc available_solvers = set(getattr(qpsolvers, "available_solvers", [])) if solver not in available_solvers: @@ -643,13 +602,6 @@ def _load_optional_dependencies(solver: str) -> _PinkModules: return _PinkModules(pink=pink, pinocchio=pinocchio) -def _import_required_module(name: str, message: str) -> ModuleType: - try: - return importlib.import_module(name) - except ImportError as exc: - raise PinkIKDependencyError(message) from exc - - def _build_joint_mapping(model: Any, config: RobotModelConfig) -> _JointMapping: idx_q: list[int] = [] model_joint_names: list[str] = [] @@ -674,9 +626,7 @@ def _build_joint_mapping(model: Any, config: RobotModelConfig) -> _JointMapping: ) -def _lock_uncontrolled_model_joints( - pinocchio: ModuleType, model: Any, config: RobotModelConfig -) -> Any: +def _lock_uncontrolled_model_joints(pinocchio: Any, model: Any, config: RobotModelConfig) -> Any: """Return a Pinocchio model reduced to the joints controlled by config.""" controlled_joint_names = set(config.joint_names) lock_joint_ids: list[int] = [] @@ -753,7 +703,7 @@ def _seed_positions_for_mapping(seed: JointState, mapping: _JointMapping) -> NDA return np.array(seed.position, dtype=np.float64) -def _matrix_to_se3(pinocchio: ModuleType, matrix: NDArray[np.float64]) -> Any: +def _matrix_to_se3(pinocchio: Any, matrix: NDArray[np.float64]) -> Any: return pinocchio.SE3(matrix[:3, :3], matrix[:3, 3]) @@ -790,17 +740,6 @@ def _failure(status: IKStatus, message: str, iterations: int = 0) -> IKResult: return IKResult(status=status, joint_state=None, iterations=iterations, message=message) -def _collision_failure(result: IKResult) -> IKResult: - return IKResult( - status=IKStatus.COLLISION, - joint_state=None, - position_error=result.position_error, - orientation_error=result.orientation_error, - iterations=result.iterations, - message="Pink IK solution rejected by collision check", - ) - - def _seed_for_robot_config( config: RobotModelConfig, seed: JointState | None, @@ -880,4 +819,17 @@ def _robot_ids_by_name( return robot_ids_by_name +def _single_pose_group_for_robot(world: WorldSpec, robot_name: RobotName) -> PlanningGroup: + configs = [world.get_robot_config(robot_id) for robot_id in world.get_robot_ids()] + registry = PlanningGroupRegistry(configs) + pose_groups = [ + group for group in registry.groups_for_robot(robot_name) if group.has_pose_target + ] + if len(pose_groups) != 1: + raise ValueError( + f"Robot '{robot_name}' has {len(pose_groups)} pose-targetable planning groups" + ) + return pose_groups[0] + + __all__ = ["PinkIK", "PinkIKConfig", "PinkIKDependencyError"] diff --git a/dimos/manipulation/planning/kinematics/test_pink_ik.py b/dimos/manipulation/planning/kinematics/test_pink_ik.py index fdc4728093..f380589542 100644 --- a/dimos/manipulation/planning/kinematics/test_pink_ik.py +++ b/dimos/manipulation/planning/kinematics/test_pink_ik.py @@ -302,12 +302,13 @@ def test_create_kinematics_pink_missing_dependency_is_actionable( ) -> None: from dimos.manipulation.planning.kinematics import pink_ik - def fake_import_module(name: str) -> ModuleType: - if name == "pink": - raise ImportError("missing pink") - return ModuleType(name) + def missing_dependencies(_solver: str) -> object: + raise PinkIKDependencyError( + "Pink IK backend requires Pink. Install manipulation dependencies with: " + "uv sync --extra manipulation. PyPI package: pin-pink; import name: pink." + ) - monkeypatch.setattr(pink_ik.importlib, "import_module", fake_import_module) + monkeypatch.setattr(pink_ik, "_load_optional_dependencies", missing_dependencies) with pytest.raises(PinkIKDependencyError) as exc_info: create_kinematics("pink") @@ -320,13 +321,13 @@ def test_create_kinematics_pink_unavailable_solver_mentions_manipulation_extra( ) -> None: from dimos.manipulation.planning.kinematics import pink_ik - def fake_import_module(name: str) -> ModuleType: - module = ModuleType(name) - if name == "qpsolvers": - module.available_solvers = [] # type: ignore[attr-defined] - return module + def unavailable_solver(_solver: str) -> object: + raise PinkIKDependencyError( + "Pink IK solver 'proxqp' is not available from qpsolvers. " + "Install manipulation dependencies with uv sync --extra manipulation." + ) - monkeypatch.setattr(pink_ik.importlib, "import_module", fake_import_module) + monkeypatch.setattr(pink_ik, "_load_optional_dependencies", unavailable_solver) with pytest.raises(PinkIKDependencyError, match="--extra manipulation"): create_kinematics("pink") @@ -460,10 +461,10 @@ def test_solve_single_reports_non_convergence() -> None: assert "did not converge" in result.message -def test_solve_rejects_collision_candidate() -> None: +def test_solve_does_not_filter_collision_candidates() -> None: ik = _pink_ik(converge=True) context = _context() - ik._robot_contexts = {"robot": context} + ik._robot_contexts = {"robot:tool": context} result = ik.solve( world=cast("Any", _FakeWorld(collision_free=False)), @@ -472,12 +473,11 @@ def test_solve_rejects_collision_candidate() -> None: position=Vector3(0.1, 0.0, 0.0), orientation=Quaternion(0.0, 0.0, 0.0, 1.0), ), - check_collision=True, max_attempts=1, ) - assert result.status == IKStatus.COLLISION - assert result.joint_state is None + assert result.status == IKStatus.SUCCESS + assert result.joint_state is not None def test_solve_pose_targets_returns_selected_resolved_joints_and_group_tip( @@ -518,7 +518,6 @@ def fake_solve_single(**kwargs: object) -> IKResult: seed=JointState( {"name": ["arm/joint_a", "arm/joint_b", "arm/joint_c"], "position": [0.0, 0.0, 0.0]} ), - check_collision=False, max_attempts=1, ) @@ -580,7 +579,6 @@ def fake_solve_multi_frame(**kwargs: object) -> IKResult: seed=JointState( {"name": ["arm/joint_a", "arm/joint_b", "arm/joint_c"], "position": [0.0, 0.0, 0.0]} ), - check_collision=False, max_attempts=1, ) @@ -636,7 +634,6 @@ def fake_solve_single(**kwargs: object) -> IKResult: seed=JointState( {"name": ["arm/joint_a", "arm/joint_b", "arm/joint_c"], "position": [0.0, 0.0, 0.0]} ), - check_collision=False, max_attempts=1, ) @@ -702,7 +699,6 @@ def fake_solve_pose_targets_for_robot(**kwargs: object) -> IKResult: name=["left/joint_a", "left/joint_b", "right/joint_c"], position=[0.0, 0.0, 0.0], ), - check_collision=False, max_attempts=1, ) @@ -716,7 +712,7 @@ def fake_solve_pose_targets_for_robot(**kwargs: object) -> IKResult: def test_solve_retries_after_joint_limit_failure(monkeypatch: pytest.MonkeyPatch) -> None: ik = _pink_ik(converge=True) context = _context() - ik._robot_contexts = {"robot": context} + ik._robot_contexts = {"robot:tool": context} calls = 0 def fake_solve_single(**_: object) -> IKResult: @@ -748,7 +744,6 @@ def fake_solve_single(**_: object) -> IKResult: position=Vector3(0.1, 0.0, 0.0), orientation=Quaternion(0.0, 0.0, 0.0, 1.0), ), - check_collision=True, max_attempts=2, ) diff --git a/dimos/manipulation/planning/monitor/test_world_monitor.py b/dimos/manipulation/planning/monitor/test_world_monitor.py index 4e12c5dd57..4a447baacd 100644 --- a/dimos/manipulation/planning/monitor/test_world_monitor.py +++ b/dimos/manipulation/planning/monitor/test_world_monitor.py @@ -29,21 +29,56 @@ from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.JointState import JointState + + +class _VectorLike(list[float]): + def tolist(self) -> list[float]: + return list(self) + + +class _FakeStateMonitor: + def __init__(self, positions: Sequence[float], stale: bool = False) -> None: + self._positions = _VectorLike(float(position) for position in positions) + self._stale = stale + + def get_current_positions(self) -> _VectorLike: + return self._positions + + def get_current_velocities(self) -> None: + return None + + def is_state_stale(self, max_age: float) -> bool: + del max_age + return self._stale + + +class _ScratchContext: + def __enter__(self) -> str: + return "scratch" + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> bool: + return False class FakeWorld: def __init__(self) -> None: - self.calls: list[tuple[str, Any]] = [] + self.calls: list[tuple[Any, ...]] = [] + self.configs: dict[str, RobotModelConfig] = {} + self.collision_free_by_robot: dict[str, bool] = {} def add_robot(self, config): + robot_id = f"robot-{len(self.configs) + 1}" + self.configs[robot_id] = config + self.collision_free_by_robot[robot_id] = True self.calls.append(("add_robot", config)) - return "robot-1" + return robot_id def get_robot_ids(self): - return [] + return list(self.configs) def get_robot_config(self, robot_id): - return None + return self.configs[robot_id] def get_joint_limits(self, robot_id): return ([], []) @@ -71,22 +106,27 @@ def is_finalized(self): return True def get_live_context(self): + self.calls.append(("get_live_context", None)) return None def scratch_context(self): - return self + self.calls.append(("scratch_context", None)) + return _ScratchContext() def sync_from_joint_state(self, robot_id, joint_state) -> None: return None def set_joint_state(self, ctx, robot_id, joint_state) -> None: + self.calls.append(("set_joint_state", ctx, robot_id, joint_state)) return None def get_joint_state(self, ctx, robot_id): + self.calls.append(("get_joint_state", ctx, robot_id)) return None def is_collision_free(self, ctx, robot_id): - return True + self.calls.append(("is_collision_free", ctx, robot_id)) + return self.collision_free_by_robot[robot_id] def get_min_distance(self, ctx, robot_id): return 0.0 @@ -165,6 +205,17 @@ def _robot_config() -> RobotModelConfig: ) +def _robot_config_named(name: str, joint_names: list[str]) -> RobotModelConfig: + return RobotModelConfig( + name=name, + model_path=Path(f"/tmp/{name}.urdf"), + base_pose=PoseStamped(position=Vector3(), orientation=Quaternion([0, 0, 0, 1])), + joint_names=joint_names, + end_effector_link="ee", + base_link="base", + ) + + def test_world_monitor_add_robot_records_scene_without_visualization_probe() -> None: fake_world = FakeWorld() fake_viz = FakeViz() @@ -209,3 +260,70 @@ def test_create_planning_specs_wraps_existing_world(monkeypatch) -> None: assert planning_specs.world_monitor.visualization is None assert planning_specs.kinematics is fake_kinematics assert planning_specs.planner is fake_planner + + +def test_current_global_joint_state_uses_fresh_monitored_state_only() -> None: + fake_world = FakeWorld() + monitor = world_monitor_module.WorldMonitor(world=fake_world) # type: ignore[arg-type] + robot_id = monitor.add_robot(_robot_config_named("arm", ["j1", "j2"])) + monitor._state_monitors[robot_id] = _FakeStateMonitor([0.1, 0.2]) # pyright: ignore[reportPrivateUsage] + + current = monitor.current_global_joint_state(max_age=0.5) + + assert current is not None + assert current.name == ["arm/j1", "arm/j2"] + assert current.position == [0.1, 0.2] + + monitor._state_monitors[robot_id] = _FakeStateMonitor([0.1, 0.2], stale=True) # pyright: ignore[reportPrivateUsage] + fake_world.calls.clear() + + assert monitor.current_global_joint_state(max_age=0.5) is None + assert not any(call[0] in {"get_live_context", "get_joint_state"} for call in fake_world.calls) + + +def test_check_collision_fills_unmentioned_joints_in_one_world_context() -> None: + fake_world = FakeWorld() + monitor = world_monitor_module.WorldMonitor(world=fake_world) # type: ignore[arg-type] + left_id = monitor.add_robot(_robot_config_named("left", ["j1", "j2"])) + right_id = monitor.add_robot(_robot_config_named("right", ["j3"])) + monitor._state_monitors[left_id] = _FakeStateMonitor([0.0, 9.0]) # pyright: ignore[reportPrivateUsage] + monitor._state_monitors[right_id] = _FakeStateMonitor([8.0]) # pyright: ignore[reportPrivateUsage] + + result = monitor.check_collision(JointState(name=["left/j1"], position=[1.0])) + + assert result.status == "VALID" + set_joint_calls = [call for call in fake_world.calls if call[0] == "set_joint_state"] + assert len(set_joint_calls) == 2 + assert {call[1] for call in set_joint_calls} == {"scratch"} + left_state = set_joint_calls[0][3] + right_state = set_joint_calls[1][3] + assert left_state.name == ["j1", "j2"] + assert left_state.position == [1.0, 9.0] + assert right_state.name == ["j3"] + assert right_state.position == [8.0] + + +def test_check_collision_reports_expected_statuses() -> None: + fake_world = FakeWorld() + monitor = world_monitor_module.WorldMonitor(world=fake_world) # type: ignore[arg-type] + robot_id = monitor.add_robot(_robot_config_named("arm", ["j1"])) + monitor._state_monitors[robot_id] = _FakeStateMonitor([0.0]) # pyright: ignore[reportPrivateUsage] + + duplicate = monitor.check_collision(JointState(name=["arm/j1", "arm/j1"], position=[1.0, 2.0])) + assert duplicate.status == "INVALID" + + local_name = monitor.check_collision(JointState(name=["j1"], position=[1.0])) + assert local_name.status == "INVALID" + + unknown = monitor.check_collision(JointState(name=["arm/missing"], position=[1.0])) + assert unknown.status == "INVALID" + + monitor._state_monitors[robot_id] = _FakeStateMonitor([0.0], stale=True) # pyright: ignore[reportPrivateUsage] + stale = monitor.check_collision(JointState(name=["arm/j1"], position=[1.0])) + assert stale.status == "STALE_STATE" + + monitor._state_monitors[robot_id] = _FakeStateMonitor([0.0]) # pyright: ignore[reportPrivateUsage] + fake_world.collision_free_by_robot[robot_id] = False + collision = monitor.check_collision(JointState(name=["arm/j1"], position=[1.0])) + assert collision.status == "COLLISION" + assert collision.collision_free is False diff --git a/dimos/manipulation/planning/monitor/world_monitor.py b/dimos/manipulation/planning/monitor/world_monitor.py index cf0c605a3c..42cd934d07 100644 --- a/dimos/manipulation/planning/monitor/world_monitor.py +++ b/dimos/manipulation/planning/monitor/world_monitor.py @@ -21,10 +21,14 @@ from typing import TYPE_CHECKING, Any from dimos.constants import DEFAULT_THREAD_JOIN_TIMEOUT +from dimos.manipulation.planning.groups.identifiers import ( + is_global_joint_name, + make_global_joint_names, +) from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry from dimos.manipulation.planning.monitor.robot_state_monitor import RobotStateMonitor from dimos.manipulation.planning.monitor.world_obstacle_monitor import WorldObstacleMonitor -from dimos.manipulation.planning.spec.models import PlanningSceneInfo +from dimos.manipulation.planning.spec.models import CollisionCheckResult, PlanningSceneInfo from dimos.manipulation.planning.spec.protocols import VisualizationSpec, WorldSpec from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -303,6 +307,34 @@ def get_current_joint_state(self, robot_id: WorldRobotID) -> JointState | None: ctx = self._world.get_live_context() return self._world.get_joint_state(ctx, robot_id) + def current_global_joint_state(self, max_age: float = 1.0) -> JointState | None: + """Return fresh monitored state for every robot using global joint names. + + This method is intentionally stricter than ``get_current_joint_state``: + it never falls back to backend live/default state. Callers use it when + stale or absent telemetry should stop a planning-world operation. + """ + names: list[str] = [] + positions: list[float] = [] + for robot_id, config in self._robot_configs.items(): + monitor = self._state_monitors.get(robot_id) + if monitor is None or monitor.is_state_stale(max_age): + return None + current = self.get_current_joint_state(robot_id) + if current is None or len(current.name) != len(current.position): + return None + positions_by_local_name = dict(zip(current.name, current.position, strict=True)) + for local_name, global_name in zip( + config.joint_names, + make_global_joint_names(config.name, config.joint_names), + strict=True, + ): + if local_name not in positions_by_local_name: + return None + names.append(global_name) + positions.append(float(positions_by_local_name[local_name])) + return JointState(name=names, position=positions) + def get_current_velocities(self, robot_id: WorldRobotID) -> JointState | None: """Get current joint velocities as JointState. Returns None if not available.""" if robot_id in self._state_monitors: @@ -342,6 +374,94 @@ def is_state_valid(self, robot_id: WorldRobotID, joint_state: JointState) -> boo """Check if configuration is collision-free.""" return self._world.check_config_collision_free(robot_id, joint_state) + def check_collision( + self, + target_joints: JointState, + max_age: float = 1.0, + ) -> CollisionCheckResult: + """Check a partial global target against the full planning-world state.""" + if not self._world.is_finalized: + return CollisionCheckResult( + status="UNAVAILABLE", + collision_free=None, + message="Planning world is not finalized", + ) + if not target_joints.name: + return CollisionCheckResult( + status="INVALID", + collision_free=None, + message="Collision target must include global joint names", + ) + if len(target_joints.name) != len(target_joints.position): + return CollisionCheckResult( + status="INVALID", + collision_free=None, + message="Collision target name and position lengths must match", + ) + if len(set(target_joints.name)) != len(target_joints.name): + return CollisionCheckResult( + status="INVALID", + collision_free=None, + message="Collision target contains duplicate joint names", + ) + invalid_names = [name for name in target_joints.name if not is_global_joint_name(name)] + if invalid_names: + return CollisionCheckResult( + status="INVALID", + collision_free=None, + message=f"Expected global joint names; got invalid names: {invalid_names}", + ) + + current = self.current_global_joint_state(max_age=max_age) + if current is None: + return CollisionCheckResult( + status="STALE_STATE", + collision_free=None, + message="Fresh monitored planning-world joint state is unavailable", + ) + + positions_by_global_name = dict(zip(current.name, current.position, strict=True)) + unknown = [name for name in target_joints.name if name not in positions_by_global_name] + if unknown: + return CollisionCheckResult( + status="INVALID", + collision_free=None, + message=f"Unknown global joint names: {unknown}", + ) + for name, position in zip(target_joints.name, target_joints.position, strict=True): + positions_by_global_name[name] = float(position) + + try: + with self._world.scratch_context() as ctx: + for robot_id, config in self._robot_configs.items(): + global_names = make_global_joint_names(config.name, config.joint_names) + local_state = JointState( + name=list(config.joint_names), + position=[positions_by_global_name[name] for name in global_names], + ) + self._world.set_joint_state(ctx, robot_id, local_state) + collision_free = all( + self._world.is_collision_free(ctx, robot_id) for robot_id in self._robot_configs + ) + except Exception as exc: + return CollisionCheckResult( + status="UNAVAILABLE", + collision_free=None, + message=f"Collision check failed: {exc}", + ) + + if collision_free: + return CollisionCheckResult( + status="VALID", + collision_free=True, + message="Target is collision-free", + ) + return CollisionCheckResult( + status="COLLISION", + collision_free=False, + message="Target is in collision", + ) + def is_path_valid( self, robot_id: WorldRobotID, path: JointPath, step_size: float = 0.05 ) -> bool: diff --git a/dimos/manipulation/planning/planners/rrt_planner.py b/dimos/manipulation/planning/planners/rrt_planner.py index 1e2d4fc396..a100376053 100644 --- a/dimos/manipulation/planning/planners/rrt_planner.py +++ b/dimos/manipulation/planning/planners/rrt_planner.py @@ -26,11 +26,11 @@ import numpy as np -from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection -from dimos.manipulation.planning.planning_identifiers import ( +from dimos.manipulation.planning.groups.identifiers import ( local_joint_name_from_global, make_global_joint_names, ) +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.enums import PlanningStatus from dimos.manipulation.planning.spec.models import ( JointPath, diff --git a/dimos/manipulation/planning/planning_identifiers.py b/dimos/manipulation/planning/planning_identifiers.py index 2f7c479e5e..5e4c87a549 100644 --- a/dimos/manipulation/planning/planning_identifiers.py +++ b/dimos/manipulation/planning/planning_identifiers.py @@ -12,106 +12,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Planning and joint identifier helpers.""" - -from __future__ import annotations - -from collections.abc import Sequence - -from dimos.manipulation.planning.spec.models import ( - GlobalJointName, - LocalModelJointName, - PlanningGroupID, - RobotName, +"""Compatibility shim for planning-group identifier helpers. + +New code should import from ``dimos.manipulation.planning.groups.identifiers``. +""" + +from dimos.manipulation.planning.groups.identifiers import ( + assert_global_joint_names, + assert_local_joint_names, + assert_valid_local_joint_name, + assert_valid_robot_name, + is_global_joint_name, + local_joint_name_from_global, + make_global_joint_name, + make_global_joint_names, + make_planning_group_id, + parse_planning_group_id, ) - -def assert_valid_robot_name(robot_name: RobotName) -> None: - """Validate a robot name for delimiter-based public IDs.""" - if not robot_name or "/" in robot_name: - raise ValueError(f"Invalid robot name: {robot_name!r}") - - -def assert_valid_local_joint_name(local_joint_name: LocalModelJointName) -> None: - """Validate a local model joint name for delimiter-based global joint names.""" - if not local_joint_name or "/" in local_joint_name: - raise ValueError(f"Invalid local joint name: {local_joint_name!r}") - - -def assert_local_joint_names(names: Sequence[LocalModelJointName]) -> None: - """Validate that names are local model joint names, not global joint names.""" - for name in names: - assert_valid_local_joint_name(name) - - -def make_planning_group_id(robot_name: RobotName, group_name: str) -> PlanningGroupID: - """Build a public planning group ID.""" - assert_valid_robot_name(robot_name) - if not group_name or "/" in group_name: - raise ValueError(f"Invalid planning group name: {group_name!r}") - return f"{robot_name}/{group_name}" - - -def parse_planning_group_id(group_id: PlanningGroupID) -> tuple[RobotName, str]: - """Split and validate a planning group ID.""" - parts = group_id.split("/", maxsplit=1) - if len(parts) != 2 or not parts[0] or not parts[1] or "/" in parts[1]: - raise ValueError( - f"Invalid planning group ID {group_id!r}; expected '{{robot_name}}/{{group_name}}'" - ) - return parts[0], parts[1] - - -def make_global_joint_name( - robot_name: RobotName, - local_joint_name: LocalModelJointName, -) -> GlobalJointName: - """Convert a local model joint name to a public global joint name.""" - assert_valid_robot_name(robot_name) - assert_valid_local_joint_name(local_joint_name) - return f"{robot_name}/{local_joint_name}" - - -def make_global_joint_names( - robot_name: RobotName, - local_joint_names: list[LocalModelJointName] | tuple[LocalModelJointName, ...], -) -> list[GlobalJointName]: - """Convert local model joint names to public global joint names.""" - return [make_global_joint_name(robot_name, name) for name in local_joint_names] - - -def is_global_joint_name(name: str) -> bool: - """Return whether name has the exact global joint-name shape.""" - parts = name.split("/") - return len(parts) == 2 and bool(parts[0]) and bool(parts[1]) - - -def assert_global_joint_names(names: Sequence[GlobalJointName]) -> None: - """Validate that names are global joint names.""" - invalid = [name for name in names if not is_global_joint_name(name)] - if invalid: - raise ValueError(f"Expected global joint names; got invalid names: {invalid}") - - -def local_joint_name_from_global( - robot_name: RobotName, - global_joint_name: GlobalJointName, -) -> LocalModelJointName: - """Validate and strip a global joint name for backend internals.""" - assert_valid_robot_name(robot_name) - prefix = f"{robot_name}/" - if not global_joint_name.startswith(prefix): - raise ValueError( - f"Global joint name {global_joint_name!r} does not belong to robot {robot_name!r}" - ) - local_name = global_joint_name[len(prefix) :] - try: - assert_valid_local_joint_name(local_name) - except ValueError as exc: - raise ValueError(f"Invalid global joint name: {global_joint_name!r}") from exc - return local_name - - __all__ = [ "assert_global_joint_names", "assert_local_joint_names", diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index cb795b2fbb..686fb652ac 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -22,11 +22,11 @@ from dimos.core.module import ModuleConfig from dimos.manipulation.planning.groups.discovery import FALLBACK_PLANNING_GROUP_NAME -from dimos.manipulation.planning.groups.models import PlanningGroupDefinition -from dimos.manipulation.planning.planning_identifiers import ( +from dimos.manipulation.planning.groups.identifiers import ( assert_local_joint_names, assert_valid_robot_name, ) +from dimos.manipulation.planning.groups.models import PlanningGroupDefinition from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped diff --git a/dimos/manipulation/planning/spec/models.py b/dimos/manipulation/planning/spec/models.py index 29e6209809..f9f87e3398 100644 --- a/dimos/manipulation/planning/spec/models.py +++ b/dimos/manipulation/planning/spec/models.py @@ -18,7 +18,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Literal, TypeAlias from dimos.manipulation.planning.spec.enums import ( IKStatus, @@ -69,6 +69,41 @@ class PlanningSceneInfo: Jacobian: TypeAlias = "NDArray[np.float64]" """6 x n Jacobian matrix (rows: [vx, vy, vz, wx, wy, wz])""" +CollisionCheckStatus: TypeAlias = Literal[ + "VALID", + "COLLISION", + "INVALID", + "UNAVAILABLE", + "STALE_STATE", +] +"""Status for a planning-world collision target check.""" + +ForwardKinematicsStatus: TypeAlias = Literal[ + "VALID", + "INVALID", + "UNAVAILABLE", + "STALE_STATE", +] +"""Status for a group-scoped forward-kinematics query.""" + + +@dataclass(frozen=True) +class CollisionCheckResult: + """Result of a planning-world collision target check.""" + + status: CollisionCheckStatus + collision_free: bool | None + message: str + + +@dataclass(frozen=True) +class ForwardKinematicsResult: + """Result of a group-scoped forward-kinematics query.""" + + status: ForwardKinematicsStatus + pose: PoseStamped | None + message: str + @dataclass class GeneratedPlan: diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index dfe7e9e427..4b14c87478 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -227,7 +227,7 @@ def close(self) -> None: @runtime_checkable class KinematicsSpec(Protocol): - """Protocol for inverse kinematics solvers. Stateless, uses WorldSpec for FK/collision.""" + """Protocol for inverse kinematics solvers. Stateless and IK-only.""" def solve( self, @@ -237,10 +237,9 @@ def solve( seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, - check_collision: bool = True, max_attempts: int = 10, ) -> IKResult: - """Solve IK with optional collision checking.""" + """Solve a single robot-scoped IK target.""" ... def solve_pose_targets( @@ -251,7 +250,6 @@ def solve_pose_targets( seed: JointState | None = None, position_tolerance: float = 0.001, orientation_tolerance: float = 0.01, - check_collision: bool = True, max_attempts: int = 10, ) -> IKResult: """Solve pose targets over planning groups plus request-scoped auxiliaries.""" diff --git a/dimos/manipulation/planning/utils/mesh_utils.py b/dimos/manipulation/planning/utils/mesh_utils.py index adb4e8f36f..7ab3941c7a 100644 --- a/dimos/manipulation/planning/utils/mesh_utils.py +++ b/dimos/manipulation/planning/utils/mesh_utils.py @@ -190,7 +190,15 @@ def _strip_transmission_blocks(urdf_content: str) -> str: def _strip_fixed_world_joint(urdf_content: str, child_link: str) -> str: - """Remove a fixed world-to-base joint so base_pose can own placement.""" + """Remove a fixed world-to-base joint so base_pose can own placement. + + ``RobotModelConfig.base_pose`` is the canonical planning-world placement. + Some URDF/xacro models also include a fixed ``world -> base`` joint; if Drake + loads that joint and the caller applies ``base_pose``, placement can be + double-applied or constrained by a model-authored weld. Strip only the fixed + world joint to the configured child link and then remove an unreferenced + ``world`` link. + """ try: root = ET.fromstring(urdf_content) except ET.ParseError: diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 23956c4a21..c9b60a378e 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -25,11 +25,11 @@ import numpy as np -from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry -from dimos.manipulation.planning.planning_identifiers import ( +from dimos.manipulation.planning.groups.identifiers import ( assert_local_joint_names, make_global_joint_name, ) +from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import ObstacleType from dimos.manipulation.planning.spec.models import ( diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 3aeae3fd92..6da9db6e76 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -34,6 +34,7 @@ from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus, PlanningStatus from dimos.manipulation.planning.spec.models import ( + CollisionCheckResult, GeneratedPlan, IKResult, PlanningSceneInfo, @@ -300,11 +301,9 @@ def test_nested_kinematics_config_parses_cli_override_shape(self) -> None: assert config.kinematics.dt == 0.02 assert config.kinematics.posture_cost == 0.0 - def test_solve_ik_rpc_calls_configured_backend(self, robot_config): - """solve_ik returns the backend IKResult without path planning.""" - module = _make_module() - module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} - module._world_monitor = MagicMock() + def test_inverse_kinematics_single_calls_configured_backend(self, robot_config): + """inverse_kinematics_single returns the backend IKResult without path planning.""" + module = _make_module_with_monitor(robot_config) module._world_monitor.world = MagicMock() current = JointState(name=robot_config.joint_names, position=[0.0, 0.0, 0.0]) module._world_monitor.get_current_joint_state.return_value = current @@ -317,43 +316,42 @@ def test_solve_ik_rpc_calls_configured_backend(self, robot_config): message="ok", ) module._kinematics = MagicMock() - module._kinematics.solve.return_value = expected + module._kinematics.solve_pose_targets.return_value = expected pose = Pose(position=Vector3(x=0.45, y=0.0, z=0.25), orientation=Quaternion()) - result = module.solve_ik(pose) + result = module.inverse_kinematics_single(pose) assert result is expected - assert module._state == ManipulationState.COMPLETED - module._kinematics.solve.assert_called_once() - _, kwargs = module._kinematics.solve.call_args + module._kinematics.solve_pose_targets.assert_called_once() + _, kwargs = module._kinematics.solve_pose_targets.call_args assert kwargs["world"] is module._world_monitor.world - assert kwargs["robot_id"] == "robot_id" - assert kwargs["seed"] is current - assert kwargs["check_collision"] is True - assert kwargs["target_pose"].frame_id == "world" - assert kwargs["target_pose"].position.x == 0.45 - - def test_solve_ik_rpc_returns_failure_without_joint_state(self, robot_config): - """solve_ik reports a failed IKResult when no seed state is available.""" - module = _make_module() - module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} - module._world_monitor = MagicMock() + assert kwargs["seed"].name == [ + "test_arm/joint1", + "test_arm/joint2", + "test_arm/joint3", + ] + assert kwargs["seed"].position == current.position + target_group, target_pose = next(iter(kwargs["pose_targets"].items())) + assert target_group.id == "test_arm/manipulator" + assert target_pose.frame_id == "world" + assert target_pose.position.x == 0.45 + + def test_inverse_kinematics_single_returns_failure_without_joint_state(self, robot_config): + """inverse_kinematics_single reports failure when no seed state is available.""" + module = _make_module_with_monitor(robot_config) module._world_monitor.get_current_joint_state.return_value = None module._kinematics = MagicMock() pose = Pose(position=Vector3(x=0.45, y=0.0, z=0.25), orientation=Quaternion()) - result = module.solve_ik(pose) + result = module.inverse_kinematics_single(pose) assert result.status == IKStatus.NO_SOLUTION assert result.message == "No joint state" - assert module._state == ManipulationState.IDLE - module._kinematics.solve.assert_not_called() + module._kinematics.solve_pose_targets.assert_not_called() - def test_solve_ik_rpc_uses_explicit_seed(self, robot_config): - """solve_ik initializes the backend from an explicit seed when provided.""" - module = _make_module() - module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} - module._world_monitor = MagicMock() + def test_inverse_kinematics_single_uses_explicit_seed(self, robot_config): + """inverse_kinematics_single initializes the backend from an explicit seed.""" + module = _make_module_with_monitor(robot_config) module._world_monitor.world = MagicMock() module._world_monitor.get_current_joint_state.return_value = JointState( name=robot_config.joint_names, position=[0.0, 0.0, 0.0] @@ -361,16 +359,49 @@ def test_solve_ik_rpc_uses_explicit_seed(self, robot_config): explicit_seed = JointState(name=robot_config.joint_names, position=[0.2, 0.1, 0.0]) expected = IKResult(status=IKStatus.SUCCESS, joint_state=explicit_seed) module._kinematics = MagicMock() - module._kinematics.solve.return_value = expected + module._kinematics.solve_pose_targets.return_value = expected pose = Pose(position=Vector3(x=0.45, y=0.0, z=0.25), orientation=Quaternion()) - result = module.solve_ik(pose, seed=explicit_seed) + result = module.inverse_kinematics_single(pose, seed=explicit_seed) assert result is expected - _, kwargs = module._kinematics.solve.call_args + _, kwargs = module._kinematics.solve_pose_targets.call_args assert kwargs["seed"] is explicit_seed module._world_monitor.get_current_joint_state.assert_not_called() + def test_forward_kinematics_accepts_extra_global_joints_and_requires_group_joints( + self, robot_config + ): + """forward_kinematics is group-centric and ignores non-group target joints.""" + module = _make_module_with_monitor(robot_config) + group = _make_global_group("test_arm", "wrist", ["joint1"]) + module._world_monitor.planning_groups = _FakePlanningGroups([group]) + module._world_monitor.get_state_monitor.return_value = MagicMock( + is_state_stale=lambda max_age: False + ) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["joint1", "joint2", "joint3"], position=[0.0, 2.0, 3.0] + ) + pose = PoseStamped(position=Vector3(x=1.0), orientation=Quaternion()) + module._world_monitor.get_group_pose.return_value = pose + + result = module.forward_kinematics( + "test_arm/wrist", + JointState(name=["test_arm/joint1", "test_arm/joint2"], position=[1.0, 9.0]), + ) + + assert result.status == "VALID" + assert result.pose is pose + resolved_state = module._world_monitor.get_group_pose.call_args.args[1] + assert resolved_state.name == ["joint1", "joint2", "joint3"] + assert resolved_state.position == [1.0, 9.0, 3.0] + + missing = module.forward_kinematics( + "test_arm/wrist", JointState(name=["test_arm/joint2"], position=[9.0]) + ) + assert missing.status == "INVALID" + assert "missing group joints" in missing.message + class TestExecute: """Test coordinator execution.""" @@ -540,6 +571,35 @@ def _make_module_with_monitor(*configs: RobotModelConfig) -> ManipulationModule: for config in configs ] ) + module._world_monitor.is_state_valid.return_value = True + + def current_global_joint_state(max_age: float = 1.0) -> JointState | None: + del max_age + names: list[str] = [] + positions: list[float] = [] + for config in configs: + current = module._world_monitor.get_current_joint_state(f"robot_{config.name}") + if current is None: + return None + current_by_name = dict(zip(current.name, current.position, strict=True)) + for local_name in config.joint_names: + if local_name not in current_by_name: + return None + names.append(f"{config.name}/{local_name}") + positions.append(float(current_by_name[local_name])) + return JointState(name=names, position=positions) + + def check_collision(target_joints: JointState, max_age: float = 1.0) -> CollisionCheckResult: + del target_joints, max_age + collision_free = bool(module._world_monitor.is_state_valid.return_value) + return CollisionCheckResult( + status="VALID" if collision_free else "COLLISION", + collision_free=collision_free, + message="Target is collision-free" if collision_free else "Target is in collision", + ) + + module._world_monitor.current_global_joint_state.side_effect = current_global_joint_state + module._world_monitor.check_collision.side_effect = check_collision return module @@ -911,7 +971,7 @@ def test_preview_plan_returns_false_for_missing_inputs(self): class TestTargetSetEvaluation: - def test_evaluate_joint_target_set_projects_selected_targets_to_full_robot_states(self): + def test_evaluate_joint_target_set_checks_selected_global_target(self): left_config = _make_robot_config("left", ["j1", "j2"], "left_task") right_config = _make_robot_config("right", ["j1", "j2"], "right_task") module = _make_module_with_monitor(left_config, right_config) @@ -942,12 +1002,9 @@ def test_evaluate_joint_target_set_projects_selected_targets_to_full_robot_state assert result["target_joints"] is not None assert result["target_joints"].name == ["left/j1", "right/j2"] assert result["target_joints"].position == [1.0, 2.0] - left_target = module._world_monitor.is_state_valid.call_args_list[0].args[1] - right_target = module._world_monitor.is_state_valid.call_args_list[1].args[1] - assert left_target.name == ["j1", "j2"] - assert left_target.position == [1.0, 9.0] - assert right_target.name == ["j1", "j2"] - assert right_target.position == [8.0, 2.0] + checked_target = module._world_monitor.check_collision.call_args.args[0] + assert checked_target.name == ["left/j1", "right/j2"] + assert checked_target.position == [1.0, 2.0] assert result["group_poses"] == {"left/arm": left_pose, "right/arm": right_pose} def test_evaluate_joint_target_set_reports_collision_per_group(self): @@ -966,7 +1023,7 @@ def test_evaluate_joint_target_set_reports_collision_per_group(self): assert result["success"] is False assert result["status"] == "COLLISION" assert result["collision_free"] is False - assert result["message"] == "Target set is in collision" + assert result["message"] == "Target is in collision" assert result["group_diagnostics"] == {"left/manipulator": "Target is in collision"} def test_evaluate_pose_target_set_uses_whole_set_seed_and_auxiliary_groups(self): diff --git a/dimos/manipulation/visualization/viser/visualizer.py b/dimos/manipulation/visualization/viser/visualizer.py index 0ea7fb575b..4d394c9294 100644 --- a/dimos/manipulation/visualization/viser/visualizer.py +++ b/dimos/manipulation/visualization/viser/visualizer.py @@ -18,7 +18,7 @@ from contextlib import suppress from typing import TYPE_CHECKING -from dimos.manipulation.planning.planning_identifiers import make_global_joint_name +from dimos.manipulation.planning.groups.identifiers import make_global_joint_name from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.animation import ( GroupPreviewAnimation, diff --git a/dimos/robot/config.py b/dimos/robot/config.py index 13a3aa83ee..3b52684bb0 100644 --- a/dimos/robot/config.py +++ b/dimos/robot/config.py @@ -29,7 +29,7 @@ from dimos.control.components import HardwareComponent, HardwareType from dimos.control.coordinator import TaskConfig from dimos.manipulation.planning.groups.discovery import discover_planning_group_definitions -from dimos.manipulation.planning.planning_identifiers import make_global_joint_names +from dimos.manipulation.planning.groups.identifiers import make_global_joint_names from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion From ee904b25f0a26f8efbb60e47361f6d425523ca2f Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 00:59:42 -0700 Subject: [PATCH 060/110] refactor: compose viser planning primitives directly --- dimos/manipulation/manipulation_module.py | 374 ------------------ dimos/manipulation/test_manipulation_unit.py | 123 ------ dimos/manipulation/visualization/viser/gui.py | 88 +++-- .../viser/{adapter.py => module_access.py} | 242 +++++++----- .../visualization/viser/test_gui_status.py | 6 +- .../viser/test_operation_worker.py | 6 +- .../viser/test_viser_visualization.py | 283 +++++-------- .../viser/test_visualizer_lifecycle.py | 13 +- .../visualization/viser/visualizer.py | 53 +-- 9 files changed, 355 insertions(+), 833 deletions(-) rename dimos/manipulation/visualization/viser/{adapter.py => module_access.py} (51%) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index bbbd876255..7756fccccc 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -79,7 +79,6 @@ NoManipulationVisualizationConfig, ) from dimos.manipulation.visualization.factory import create_manipulation_visualization -from dimos.manipulation.visualization.types import TargetEvaluation, TargetSetEvaluation from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -1131,379 +1130,6 @@ def get_init_joints(self, robot_name: RobotName | None = None) -> JointState | N return None return self._init_joints.get(robot[0]) - def evaluate_joint_target( - self, joints: JointState | None, robot_name: RobotName - ) -> TargetEvaluation: - """Evaluate a joint target for visualization without planning a path.""" - robot_id = self.robot_id_for_name(robot_name) - if robot_id is None or self._world_monitor is None: - return { - "success": False, - "status": "NO_ROBOT", - "message": f"Unknown robot: {robot_name}", - "collision_free": False, - "ee_pose": None, - "joint_state": None, - } - if joints is None: - return { - "success": False, - "status": "NO_TARGET", - "message": "No joint target provided", - "collision_free": False, - "ee_pose": None, - "joint_state": None, - } - target = JointState(joints) - collision_free = self._world_monitor.is_state_valid(robot_id, target) - return { - "success": True, - "status": "FEASIBLE" if collision_free else "COLLISION", - "message": "Target is collision-free" if collision_free else "Target is in collision", - "collision_free": collision_free, - "ee_pose": self._world_monitor.get_ee_pose(robot_id, target), - "joint_state": target, - } - - def evaluate_pose_target( - self, pose: Pose, robot_name: RobotName, check_collision: bool = True - ) -> TargetEvaluation: - """Evaluate a Cartesian target for visualization without planning a path.""" - robot_id = self.robot_id_for_name(robot_name) - if robot_id is None: - return { - "success": False, - "joint_state": None, - "status": "UNKNOWN_ROBOT", - "message": f"Unknown robot: {robot_name}", - "collision_free": False, - } - if self._world_monitor is None or self._kinematics is None: - return { - "success": False, - "joint_state": None, - "status": "UNAVAILABLE", - "message": "Planning is not initialized or current state is unavailable", - "collision_free": False, - } - ik = self.inverse_kinematics_single(pose, robot_name=robot_name) - joint_state = JointState(ik.joint_state) if ik.is_success() and ik.joint_state else None - collision = self.check_collision(joint_state) if joint_state is not None else None - collision_free = bool( - joint_state is not None - and ( - not check_collision or (collision is not None and collision.collision_free is True) - ) - ) - return { - "success": joint_state is not None and collision_free, - "joint_state": joint_state, - "status": ik.status.name, - "message": ik.message if collision is None else collision.message, - "position_error": ik.position_error, - "orientation_error": ik.orientation_error, - "collision_free": collision_free, - } - - def _selected_target_by_name( - self, group_ids: tuple[PlanningGroupID, ...], target_joints: JointState - ) -> dict[str, float] | None: - """Validate and index a global target joint state for a group selection.""" - assert self._world_monitor is not None - selection = self._world_monitor.planning_groups.select(group_ids) - if len(target_joints.name) != len(target_joints.position): - logger.error( - "Target set has %d names but %d positions", - len(target_joints.name), - len(target_joints.position), - ) - return None - try: - assert_global_joint_names(target_joints.name) - except ValueError as exc: - logger.error("Invalid target-set joint names: %s", exc) - return None - - target_by_name = dict(zip(target_joints.name, target_joints.position, strict=True)) - missing = [name for name in selection.joint_names if name not in target_by_name] - extra = set(target_by_name) - set(selection.joint_names) - if missing: - logger.error("Target set missing joints: %s", missing) - return None - if extra: - logger.error("Target set has extra joints: %s", sorted(extra)) - return None - return target_by_name - - def _local_target_states_for_selection( - self, group_ids: tuple[PlanningGroupID, ...], target_joints: JointState - ) -> dict[RobotName, tuple[WorldRobotID, RobotModelConfig, JointState]] | None: - """Project selected global targets to full local robot states for evaluation.""" - assert self._world_monitor is not None - selection = self._world_monitor.planning_groups.select(group_ids) - target_by_name = self._selected_target_by_name(group_ids, target_joints) - if target_by_name is None: - return None - - local_targets: dict[RobotName, tuple[WorldRobotID, RobotModelConfig, JointState]] = {} - for robot_name in selection.robot_names: - robot = self._robots.get(robot_name) - if robot is None: - logger.error("Robot '%s' is not registered", robot_name) - return None - robot_id, config, _ = robot - current = self._world_monitor.get_current_joint_state(robot_id) - if current is None: - logger.error("No joint state for robot '%s'", robot_name) - return None - current_by_name = self._current_positions_by_name(robot_name, current) - if current_by_name is None: - return None - - positions: list[float] = [] - for local_name, global_name in zip( - config.joint_names, - make_global_joint_names(robot_name, config.joint_names), - strict=True, - ): - if global_name in target_by_name: - positions.append(float(target_by_name[global_name])) - elif local_name in current_by_name: - positions.append(float(current_by_name[local_name])) - else: - logger.error("Current state missing joint '%s'", global_name) - return None - local_targets[robot_name] = ( - robot_id, - config, - JointState(name=list(config.joint_names), position=positions), - ) - return local_targets - - def _target_set_poses( - self, - group_ids: tuple[PlanningGroupID, ...], - local_targets: Mapping[RobotName, tuple[WorldRobotID, RobotModelConfig, JointState]], - ) -> dict[PlanningGroupID, PoseStamped | None]: - """Compute pose outputs for pose-targetable groups in a target set.""" - assert self._world_monitor is not None - poses: dict[PlanningGroupID, PoseStamped | None] = {} - for group in self._world_monitor.planning_groups.select(group_ids).groups: - if not group.has_pose_target: - continue - robot_target = local_targets.get(group.robot_name) - if robot_target is None: - poses[group.id] = None - continue - _, _, joint_state = robot_target - try: - poses[group.id] = self._world_monitor.get_group_pose(group.id, joint_state) - except Exception as exc: - logger.warning("Failed to evaluate pose for group '%s': %s", group.id, exc) - poses[group.id] = None - return poses - - def _evaluate_global_target_set( - self, - group_ids: tuple[PlanningGroupID, ...], - target_joints: JointState | None, - *, - status: str = "FEASIBLE", - message: str = "Target set is feasible", - position_error: float = 0.0, - orientation_error: float = 0.0, - check_collision: bool = True, - ) -> TargetSetEvaluation: - """Evaluate whole-set collision and pose outputs for global target joints.""" - if self._world_monitor is None: - return { - "success": False, - "status": "UNAVAILABLE", - "message": "Planning is not initialized", - "collision_free": False, - "group_ids": group_ids, - "target_joints": None, - } - if target_joints is None: - return { - "success": False, - "status": "NO_TARGET", - "message": "No target joints provided", - "collision_free": False, - "group_ids": group_ids, - "target_joints": None, - } - try: - local_targets = self._local_target_states_for_selection(group_ids, target_joints) - except (KeyError, ValueError) as exc: - return { - "success": False, - "status": "INVALID", - "message": str(exc), - "collision_free": False, - "group_ids": group_ids, - "target_joints": None, - } - if local_targets is None: - return { - "success": False, - "status": "INVALID", - "message": "Invalid target-set joints", - "collision_free": False, - "group_ids": group_ids, - "target_joints": None, - } - - collision = self.check_collision(target_joints) if check_collision else None - collision_free = True if collision is None else collision.collision_free is True - diagnostics: dict[PlanningGroupID, str] = {} - selection = self._world_monitor.planning_groups.select(group_ids) - for group in selection.groups: - if not check_collision: - diagnostics[group.id] = "Target collision check skipped" - else: - diagnostics[group.id] = ( - collision.message if collision is not None else "Target checked" - ) - - return { - "success": collision_free, - "status": status - if collision_free - else (collision.status if collision is not None else "COLLISION"), - "message": message - if collision_free - else (collision.message if collision is not None else "Target set is in collision"), - "collision_free": collision_free, - "group_ids": group_ids, - "target_joints": JointState(target_joints), - "group_diagnostics": diagnostics, - "group_poses": self._target_set_poses(group_ids, local_targets), - "position_error": position_error, - "orientation_error": orientation_error, - } - - def evaluate_joint_target_set( - self, joint_targets: Mapping[PlanningGroupID | PlanningGroup, JointState] - ) -> TargetSetEvaluation: - """Evaluate joint targets for a whole planning target set.""" - if self._world_monitor is None: - return { - "success": False, - "status": "UNAVAILABLE", - "message": "Planning is not initialized", - "collision_free": False, - "target_joints": None, - } - if not joint_targets: - return { - "success": False, - "status": "NO_TARGET", - "message": "No joint targets provided", - "collision_free": False, - "target_joints": None, - } - - group_ids = tuple( - dict.fromkeys(planning_group_id_from_selector(group) for group in joint_targets) - ) - goal_names: list[str] = [] - goal_positions: list[float] = [] - for group_selector, target in joint_targets.items(): - group_id = planning_group_id_from_selector(group_selector) - target_global = self._joint_target_to_global_names(group_id, target) - if target_global is None: - return { - "success": False, - "status": "INVALID", - "message": f"Invalid joint target for '{group_id}'", - "collision_free": False, - "group_ids": group_ids, - "target_joints": None, - } - goal_names.extend(target_global.name) - goal_positions.extend(target_global.position) - - return self._evaluate_global_target_set( - group_ids, - JointState(name=goal_names, position=goal_positions), - ) - - def evaluate_pose_target_set( - self, - pose_targets: Mapping[PlanningGroupID | PlanningGroup, Pose | PoseStamped], - auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup] = (), - seed: JointState | None = None, - check_collision: bool = True, - ) -> TargetSetEvaluation: - """Evaluate pose targets for a whole planning target set using configured IK.""" - if self._world_monitor is None or self._kinematics is None: - return { - "success": False, - "status": "UNAVAILABLE", - "message": "Planning is not initialized", - "collision_free": False, - "target_joints": None, - } - if not pose_targets: - return { - "success": False, - "status": "NO_TARGET", - "message": "No pose targets provided", - "collision_free": False, - "target_joints": None, - } - - def stamped_pose(pose: Pose | PoseStamped) -> PoseStamped: - if isinstance(pose, PoseStamped): - return pose - return PoseStamped( - frame_id="world", position=pose.position, orientation=pose.orientation - ) - - stamped_targets = { - planning_group_id_from_selector(group): stamped_pose(pose) - for group, pose in pose_targets.items() - } - auxiliary_ids = tuple(planning_group_id_from_selector(group) for group in auxiliary_groups) - group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_ids))) - try: - ik = self.inverse_kinematics( - pose_targets=stamped_targets, - auxiliary_group_ids=auxiliary_ids, - seed=seed or self._selected_joint_state(group_ids), - ) - except (KeyError, ValueError) as exc: - return { - "success": False, - "status": "INVALID", - "message": str(exc), - "collision_free": False, - "group_ids": group_ids, - "target_joints": None, - } - - if not ik.is_success() or ik.joint_state is None: - return { - "success": False, - "status": ik.status.name, - "message": ik.message, - "collision_free": False, - "group_ids": group_ids, - "target_joints": None, - "position_error": ik.position_error, - "orientation_error": ik.orientation_error, - } - return self._evaluate_global_target_set( - group_ids, - ik.joint_state, - status=ik.status.name, - message=ik.message, - position_error=ik.position_error, - orientation_error=ik.orientation_error, - check_collision=check_collision, - ) - @rpc def set_init_joints(self, joint_state: JointState, robot_name: RobotName | None = None) -> bool: """Set the init joint state. diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 6da9db6e76..ca453301f8 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -970,129 +970,6 @@ def test_preview_plan_returns_false_for_missing_inputs(self): assert module.preview_plan() is False -class TestTargetSetEvaluation: - def test_evaluate_joint_target_set_checks_selected_global_target(self): - left_config = _make_robot_config("left", ["j1", "j2"], "left_task") - right_config = _make_robot_config("right", ["j1", "j2"], "right_task") - module = _make_module_with_monitor(left_config, right_config) - module._world_monitor.planning_groups = _FakePlanningGroups( - [ - _make_global_group("left", "arm", ["j1"]), - _make_global_group("right", "arm", ["j2"]), - ] - ) - module._world_monitor.get_current_joint_state.side_effect = [ - JointState(name=["j1", "j2"], position=[0.0, 9.0]), - JointState(name=["j1", "j2"], position=[8.0, 0.0]), - ] - module._world_monitor.is_state_valid.return_value = True - left_pose = PoseStamped(position=Vector3(x=1.0), orientation=Quaternion()) - right_pose = PoseStamped(position=Vector3(x=2.0), orientation=Quaternion()) - module._world_monitor.get_group_pose.side_effect = [left_pose, right_pose] - - result = module.evaluate_joint_target_set( - { - "left/arm": JointState(name=["j1"], position=[1.0]), - "right/arm": JointState(name=["j2"], position=[2.0]), - } - ) - - assert result["success"] is True - assert result["collision_free"] is True - assert result["target_joints"] is not None - assert result["target_joints"].name == ["left/j1", "right/j2"] - assert result["target_joints"].position == [1.0, 2.0] - checked_target = module._world_monitor.check_collision.call_args.args[0] - assert checked_target.name == ["left/j1", "right/j2"] - assert checked_target.position == [1.0, 2.0] - assert result["group_poses"] == {"left/arm": left_pose, "right/arm": right_pose} - - def test_evaluate_joint_target_set_reports_collision_per_group(self): - config = _make_robot_config("left", ["j1"], "task") - module = _make_module_with_monitor(config) - module._world_monitor.get_current_joint_state.return_value = JointState( - name=["j1"], position=[0.0] - ) - module._world_monitor.is_state_valid.return_value = False - module._world_monitor.get_group_pose.return_value = None - - result = module.evaluate_joint_target_set( - {"left/manipulator": JointState(name=["j1"], position=[1.0])} - ) - - assert result["success"] is False - assert result["status"] == "COLLISION" - assert result["collision_free"] is False - assert result["message"] == "Target is in collision" - assert result["group_diagnostics"] == {"left/manipulator": "Target is in collision"} - - def test_evaluate_pose_target_set_uses_whole_set_seed_and_auxiliary_groups(self): - config = _make_robot_config("left", ["j1", "j2"], "task") - arm_group = _make_global_group("left", "arm", ["j1"]) - wrist_group = _make_global_group("left", "wrist", ["j2"]) - module = _make_module_with_monitor(config) - module._world_monitor.planning_groups = _FakePlanningGroups([arm_group, wrist_group]) - module._world_monitor.get_current_joint_state.return_value = JointState( - name=["j1", "j2"], position=[0.1, 0.2] - ) - module._world_monitor.is_state_valid.return_value = True - module._world_monitor.get_group_pose.return_value = PoseStamped( - position=Vector3(x=1.0), orientation=Quaternion() - ) - module._kinematics = MagicMock() - module._kinematics.solve_pose_targets.return_value = IKResult( - status=IKStatus.SUCCESS, - joint_state=JointState(name=["left/j1", "left/j2"], position=[1.0, 2.0]), - message="solved", - position_error=0.01, - orientation_error=0.02, - ) - pose = Pose(position=Vector3(x=0.5), orientation=Quaternion()) - - result = module.evaluate_pose_target_set( - {"left/arm": pose}, auxiliary_groups=("left/wrist",) - ) - - assert result["success"] is True - assert result["target_joints"] is not None - assert result["target_joints"].name == ["left/j1", "left/j2"] - assert result["target_joints"].position == [1.0, 2.0] - _, kwargs = module._kinematics.solve_pose_targets.call_args - assert list(kwargs["pose_targets"]) == [arm_group] - assert kwargs["auxiliary_groups"] == (wrist_group,) - assert kwargs["seed"].name == ["left/j1", "left/j2"] - assert kwargs["seed"].position == [0.1, 0.2] - - def test_evaluate_pose_target_set_can_skip_collision_checking(self): - config = _make_robot_config("left", ["j1"], "task") - group = _make_global_group("left", "arm", ["j1"]) - module = _make_module_with_monitor(config) - module._world_monitor.planning_groups = _FakePlanningGroups([group]) - module._world_monitor.get_current_joint_state.return_value = JointState( - name=["j1"], position=[0.0] - ) - module._world_monitor.is_state_valid.return_value = False - module._world_monitor.get_group_pose.return_value = PoseStamped( - position=Vector3(x=1.0), orientation=Quaternion() - ) - module._kinematics = MagicMock() - module._kinematics.solve_pose_targets.return_value = IKResult( - status=IKStatus.SUCCESS, - joint_state=JointState(name=["left/j1"], position=[1.0]), - message="solved", - ) - - result = module.evaluate_pose_target_set( - {"left/arm": Pose(position=Vector3(x=0.5), orientation=Quaternion())}, - check_collision=False, - ) - - assert result["success"] is True - assert result["collision_free"] is True - assert result["group_diagnostics"] == {"left/arm": "Target collision check skipped"} - module._world_monitor.is_state_valid.assert_not_called() - - class TestGeneratedPlanProjection: def test_selected_joint_state_accepts_local_current_state_names(self): config = _make_robot_config("left", ["j1", "j2"], "task") diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 5bb7a00d0c..57e5965f61 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import TypeAlias +from typing import TYPE_CHECKING, TypeAlias, cast from dimos.manipulation.planning.spec.models import PlanningGroupID from dimos.manipulation.visualization.types import ( @@ -22,8 +22,8 @@ TargetEvaluation, TargetSetEvaluation, ) -from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig +from dimos.manipulation.visualization.viser.module_access import ViserModuleAccess from dimos.manipulation.visualization.viser.runtime import VISER_INSTALL_HINT from dimos.manipulation.visualization.viser.scene import ViserManipulationScene from dimos.manipulation.visualization.viser.state import ( @@ -43,6 +43,10 @@ from dimos.msgs.sensor_msgs.JointState import JointState from dimos.utils.logging_config import setup_logger +if TYPE_CHECKING: + from dimos.manipulation.manipulation_module import ManipulationModule + from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor + logger = setup_logger() try: @@ -84,14 +88,25 @@ class ViserPanelGui: def __init__( self, server: ViserServer, - adapter: InProcessViserAdapter, - config: ViserVisualizationConfig, + world_monitor: WorldMonitor | ViserModuleAccess, + manipulation_module: ManipulationModule | ViserVisualizationConfig, + config: ViserVisualizationConfig | ViserManipulationScene | None = None, scene: ViserManipulationScene | None = None, ) -> None: self.server = server - self.adapter = adapter - self.config = config - self.scene = scene + if isinstance(manipulation_module, ViserVisualizationConfig): + self.adapter = cast("ViserModuleAccess", world_monitor) + self.config = manipulation_module + self.scene = cast("ViserManipulationScene | None", config) + else: + assert not isinstance(manipulation_module, ViserVisualizationConfig) + assert isinstance(config, ViserVisualizationConfig) + self.adapter = ViserModuleAccess( + cast("WorldMonitor", world_monitor), + cast("ManipulationModule", manipulation_module), + ) + self.config = config + self.scene = scene self.state = PanelState(runtime=PanelRuntime.STARTING) self._closed = False self._operation_sequence_id = 0 @@ -302,11 +317,7 @@ def _build_joint_slider_handles(self, gui: GuiApi) -> None: continue config = self.adapter.get_robot_config(str(group["robot_name"])) current = self.adapter.get_current_joint_state(str(group["robot_name"])) - current_by_name = ( - dict(zip(current.name, current.position, strict=False)) - if current is not None - else {} - ) + current_by_name = self._joint_values_by_name(str(group["robot_name"]), current) joint_limits_lower = config.joint_limits_lower if config is not None else None joint_limits_upper = config.joint_limits_upper if config is not None else None for index, (global_name, local_name) in enumerate( @@ -314,7 +325,14 @@ def _build_joint_slider_handles(self, gui: GuiApi) -> None: ): joint_name = str(global_name) local = str(local_name) - value = float(target_by_name.get(joint_name, current_by_name.get(local, 0.0))) + value = float( + target_by_name.get( + joint_name, + target_by_name.get( + local, current_by_name.get(joint_name, current_by_name.get(local, 0.0)) + ), + ) + ) lower, upper = DEFAULT_JOINT_LIMITS if joint_limits_lower is not None and index < len(joint_limits_lower): lower = joint_limits_lower[index] @@ -533,10 +551,13 @@ def _initialize_selected_group_targets(self) -> None: current = self.adapter.get_current_joint_state(str(group["robot_name"])) if current is None: continue - current_by_name = dict(zip(current.name, current.position, strict=False)) + current_by_name = self._joint_values_by_name(str(group["robot_name"]), current) names = [str(name) for name in group["joint_names"]] local_names = [str(name) for name in group["local_joint_names"]] - positions = [float(current_by_name.get(local, 0.0)) for local in local_names] + positions = [ + float(current_by_name.get(global_name, current_by_name.get(local, 0.0))) + for global_name, local in zip(names, local_names, strict=False) + ] self.state.group_joint_targets[group_id] = JointState( {"name": names, "position": positions} ) @@ -572,13 +593,32 @@ def _current_snapshot_by_group(self) -> dict[PlanningGroupID, list[float]]: current = self.adapter.get_current_joint_state(str(group["robot_name"])) if current is None: continue - current_by_name = dict(zip(current.name, current.position, strict=False)) + current_by_name = self._joint_values_by_name(str(group["robot_name"]), current) snapshot[group_id] = [ - float(current_by_name.get(str(local_name), 0.0)) - for local_name in group["local_joint_names"] + float( + current_by_name.get(str(global_name), current_by_name.get(str(local_name), 0.0)) + ) + for global_name, local_name in zip( + group["joint_names"], group["local_joint_names"], strict=False + ) ] return snapshot + @staticmethod + def _joint_values_by_name(robot_name: str, joint_state: JointState | None) -> dict[str, float]: + if joint_state is None: + return {} + values: dict[str, float] = {} + for name, position in zip(joint_state.name, joint_state.position, strict=False): + name_str = str(name) + position_float = float(position) + values[name_str] = position_float + if "/" in name_str: + values[name_str.rsplit("/", 1)[1]] = position_float + else: + values[f"{robot_name}/{name_str}"] = position_float + return values + def _sync_preset_dropdown(self) -> None: handle = self._handles.get("preset") if handle is None: @@ -675,18 +715,6 @@ def _set_slider_values(self, joint_names: list[str], values: list[float]) -> Non finally: self._suppress_target_callbacks = False - def _target_from_sliders(self, robot_name: str) -> JointState | None: - config = self.adapter.get_robot_config(robot_name) - if config is None: - self._set_error("No robot config") - return None - values = [ - float(self._joint_sliders[name].value) - for name in config.joint_names - if name in self._joint_sliders - ] - return self.adapter.joints_from_values(config.joint_names, values) - def _on_joint_slider_update(self, _joint_name: str) -> None: if self._closed: return diff --git a/dimos/manipulation/visualization/viser/adapter.py b/dimos/manipulation/visualization/viser/module_access.py similarity index 51% rename from dimos/manipulation/visualization/viser/adapter.py rename to dimos/manipulation/visualization/viser/module_access.py index 82acc1f63a..2ca38863d2 100644 --- a/dimos/manipulation/visualization/viser/adapter.py +++ b/dimos/manipulation/visualization/viser/module_access.py @@ -14,15 +14,17 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, cast +from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName, WorldRobotID from dimos.manipulation.visualization.types import ( PlanningGroupInfo, RobotInfo, - TargetEvaluation, TargetSetEvaluation, ) +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState if TYPE_CHECKING: @@ -30,9 +32,6 @@ from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor from dimos.manipulation.planning.spec.config import RobotModelConfig - from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName, WorldRobotID - from dimos.msgs.geometry_msgs.Pose import Pose - from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped def copy_joint_state(joint_state: JointState | None) -> JointState | None: @@ -40,14 +39,11 @@ def copy_joint_state(joint_state: JointState | None) -> JointState | None: return None if joint_state is None else JointState(joint_state) -class InProcessViserAdapter: - """Small in-process boundary between Viser callbacks and manipulation internals.""" +class ViserModuleAccess: + """Direct in-process access from Viser UI code to ManipulationModule primitives.""" def __init__( - self, - *, - world_monitor: WorldMonitor, - manipulation_module: ManipulationModule, + self, world_monitor: WorldMonitor, manipulation_module: ManipulationModule ) -> None: self._world_monitor = world_monitor self._module = manipulation_module @@ -110,19 +106,20 @@ def list_planning_groups(self) -> list[PlanningGroupInfo]: groups: list[PlanningGroupInfo] = [] for robot_name in self.list_robots(): info = self.get_robot_info(robot_name) - if info is None: - continue - groups.extend(info.get("planning_groups", [])) + if info is not None: + groups.extend(info.get("planning_groups", [])) return groups def get_init_joints(self, robot_name: RobotName) -> JointState | None: return copy_joint_state(self._module.get_init_joints(robot_name)) def get_current_joint_state(self, robot_name: RobotName) -> JointState | None: - robot_id = self.robot_id_for_name(robot_name) - if robot_id is None: - return None - return copy_joint_state(self._world_monitor.get_current_joint_state(robot_id)) + current = self._module.get_current_joint_state(robot_name) + if current is None: + robot_id = self.robot_id_for_name(robot_name) + if robot_id is not None: + current = self._world_monitor.get_current_joint_state(robot_id) + return copy_joint_state(current) def is_state_stale(self, robot_name: RobotName, max_age: float = 1.0) -> bool: robot_id = self.robot_id_for_name(robot_name) @@ -130,76 +127,22 @@ def is_state_stale(self, robot_name: RobotName, max_age: float = 1.0) -> bool: def get_ee_pose( self, robot_name: RobotName, joint_state: JointState | None = None - ) -> PoseStamped | None: + ) -> PoseStamped | Pose | None: + group_id = self._primary_pose_group_id(robot_name) + if group_id is None: + return None + fk = self._module.forward_kinematics(group_id, copy_joint_state(joint_state)) + if fk.pose is not None: + return fk.pose robot_id = self.robot_id_for_name(robot_name) if robot_id is None: return None - return self._world_monitor.get_ee_pose(robot_id, copy_joint_state(joint_state)) - - def evaluate_joint_target(self, joints: JointState, robot_name: RobotName) -> TargetEvaluation: - """Evaluate a joint target through WorldMonitor helpers, not raw WorldSpec access.""" - result: TargetEvaluation = { - **self._module.evaluate_joint_target(copy_joint_state(joints), robot_name) - } - joint_state = result.get("joint_state") - result["joint_state"] = copy_joint_state( - joint_state if isinstance(joint_state, JointState) else None - ) - return result - - def evaluate_pose_target( - self, pose: Pose, robot_name: RobotName, *, check_collision: bool = True - ) -> TargetEvaluation: - """Evaluate a Cartesian target through module/WorldMonitor helper boundaries.""" - result: TargetEvaluation = { - **self._module.evaluate_pose_target(pose, robot_name, check_collision=check_collision) - } - joint_state = result.get("joint_state") - result["joint_state"] = copy_joint_state( - joint_state if isinstance(joint_state, JointState) else None - ) - return result - - def evaluate_joint_target_set( - self, joint_targets: dict[PlanningGroupID, JointState] - ) -> TargetSetEvaluation: - result: TargetSetEvaluation = { - **self._module.evaluate_joint_target_set( - cast( - "dict[PlanningGroupID | PlanningGroup, JointState]", - { - group_id: copy_joint_state(target) or target - for group_id, target in joint_targets.items() - }, - ) + get_ee_pose = getattr(self._world_monitor, "get_ee_pose", None) + if callable(get_ee_pose): + return cast( + "PoseStamped | Pose | None", get_ee_pose(robot_id, copy_joint_state(joint_state)) ) - } - target_joints = result.get("target_joints") - result["target_joints"] = copy_joint_state( - target_joints if isinstance(target_joints, JointState) else None - ) - return result - - def evaluate_pose_target_set( - self, - pose_targets: dict[PlanningGroupID, Pose | PoseStamped], - auxiliary_groups: Sequence[PlanningGroupID] = (), - seed: JointState | None = None, - check_collision: bool = True, - ) -> TargetSetEvaluation: - result: TargetSetEvaluation = { - **self._module.evaluate_pose_target_set( - cast("dict[PlanningGroupID | PlanningGroup, Pose | PoseStamped]", pose_targets), - auxiliary_groups=auxiliary_groups, - seed=copy_joint_state(seed), - check_collision=check_collision, - ) - } - target_joints = result.get("target_joints") - result["target_joints"] = copy_joint_state( - target_joints if isinstance(target_joints, JointState) else None - ) - return result + return None def get_module_state(self) -> str: return str(self._module.get_state()) @@ -208,13 +151,8 @@ def get_error(self) -> str: return self._module.get_error() def reset(self) -> bool: - return self._module.reset().is_success() - - def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: - return self._module.plan_to_pose(pose, robot_name) - - def plan_to_joints(self, joints: JointState, robot_name: RobotName | None = None) -> bool: - return self._module.plan_to_joints(joints, robot_name) + result = self._module.reset() + return result if isinstance(result, bool) else result.is_success() def plan_target_set(self, joint_targets: dict[PlanningGroupID, JointState]) -> bool: return self._module.plan_to_joint_targets( @@ -233,11 +171,125 @@ def cancel(self) -> bool: def clear_planned_path(self) -> bool: return self._module.clear_planned_path() + def evaluate_joint_target_set( + self, joint_targets: Mapping[PlanningGroupID, JointState] + ) -> TargetSetEvaluation: + if not joint_targets: + return {"success": False, "status": "INVALID", "message": "No joint target"} + names: list[str] = [] + positions: list[float] = [] + for target in joint_targets.values(): + copied = copy_joint_state(target) + if copied is None: + continue + names.extend(str(name) for name in copied.name) + positions.extend(float(position) for position in copied.position) + return self._evaluate_global_target_set( + tuple(joint_targets), JointState({"name": names, "position": positions}) + ) + + def evaluate_pose_target_set( + self, + pose_targets: Mapping[PlanningGroupID, Pose | PoseStamped], + auxiliary_groups: Sequence[PlanningGroupID] = (), + seed: JointState | None = None, + check_collision: bool = True, + ) -> TargetSetEvaluation: + if not pose_targets: + return {"success": False, "status": "INVALID", "message": "No pose target"} + stamped_targets = { + group_id: self._stamped_pose(pose) for group_id, pose in pose_targets.items() + } + group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_groups))) + ik = self._module.inverse_kinematics( + pose_targets=stamped_targets, + auxiliary_group_ids=auxiliary_groups, + seed=copy_joint_state(seed), + ) + if not ik.is_success() or ik.joint_state is None: + return { + "success": False, + "status": ik.status.name, + "message": ik.message, + "collision_free": False, + "group_ids": group_ids, + "target_joints": None, + "position_error": ik.position_error, + "orientation_error": ik.orientation_error, + } + return self._evaluate_global_target_set( + group_ids, + ik.joint_state, + status=ik.status.name, + message=ik.message, + position_error=ik.position_error, + orientation_error=ik.orientation_error, + check_collision=check_collision, + ) + + def _evaluate_global_target_set( + self, + group_ids: tuple[PlanningGroupID, ...], + target_joints: JointState, + *, + status: str = "FEASIBLE", + message: str = "Target is collision-free", + position_error: float = 0.0, + orientation_error: float = 0.0, + check_collision: bool = True, + ) -> TargetSetEvaluation: + target = copy_joint_state(target_joints) + if target is None: + return {"success": False, "status": "INVALID", "message": "No target joints"} + collision = self._module.check_collision(target) if check_collision else None + collision_free = True if collision is None else collision.collision_free is True + diagnostics = { + group_id: "Target collision check skipped" if collision is None else collision.message + for group_id in group_ids + } + result_message = message + if collision is None: + result_message = "Target collision check skipped" + elif not collision_free: + result_message = collision.message + group_poses: dict[PlanningGroupID, PoseStamped | Pose | None] = {} + for group_id in group_ids: + fk = self._module.forward_kinematics(group_id, target) + if fk.pose is not None: + group_poses[group_id] = fk.pose + elif fk.status != "INVALID": + group_poses[group_id] = None + return { + "success": collision_free, + "status": status + if collision_free + else collision.status + if collision is not None + else "COLLISION", + "message": result_message, + "collision_free": collision_free, + "group_ids": group_ids, + "target_joints": target, + "group_diagnostics": diagnostics, + "group_poses": group_poses, + "position_error": position_error, + "orientation_error": orientation_error, + } + + def _primary_pose_group_id(self, robot_name: RobotName) -> PlanningGroupID | None: + for group in self.list_planning_groups(): + if str(group["robot_name"]) == robot_name and bool(group["has_pose_target"]): + return str(group["id"]) + return None + + @staticmethod + def _stamped_pose(pose: Pose | PoseStamped) -> PoseStamped: + if isinstance(pose, PoseStamped): + return pose + return PoseStamped(frame_id="world", position=pose.position, orientation=pose.orientation) + @staticmethod def joints_from_values(joint_names: Sequence[str], values: Sequence[float]) -> JointState: return JointState( - { - "name": list(joint_names), - "position": [float(value) for value in values], - } + {"name": list(joint_names), "position": [float(value) for value in values]} ) diff --git a/dimos/manipulation/visualization/viser/test_gui_status.py b/dimos/manipulation/visualization/viser/test_gui_status.py index f461565bdf..4ebff5e5c3 100644 --- a/dimos/manipulation/visualization/viser/test_gui_status.py +++ b/dimos/manipulation/visualization/viser/test_gui_status.py @@ -19,7 +19,6 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") from dimos.manipulation.visualization.types import TargetEvaluation -from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig from dimos.manipulation.visualization.viser.gui import ViserPanelGui from dimos.manipulation.visualization.viser.state import FeasibilityStatus @@ -29,9 +28,8 @@ class StatusOnlyServer: pass -class StatusOnlyAdapter(InProcessViserAdapter): - def __init__(self) -> None: - pass +class StatusOnlyAdapter: + pass @pytest.mark.parametrize( diff --git a/dimos/manipulation/visualization/viser/test_operation_worker.py b/dimos/manipulation/visualization/viser/test_operation_worker.py index b6d0f4a7f5..035fa6f2a7 100644 --- a/dimos/manipulation/visualization/viser/test_operation_worker.py +++ b/dimos/manipulation/visualization/viser/test_operation_worker.py @@ -23,7 +23,6 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") from dimos.manipulation.visualization.types import RobotInfo -from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig from dimos.manipulation.visualization.viser.gui import ViserPanelGui from dimos.manipulation.visualization.viser.state import ( @@ -106,13 +105,16 @@ def stop(self, timeout: float | None = 2.0) -> None: self.stop_calls.append(timeout) -class FakeOperationAdapter(InProcessViserAdapter): +class FakeOperationAdapter: def __init__(self) -> None: self.cancel_calls = 0 def list_robots(self) -> list[str]: return [] + def list_planning_groups(self) -> list[dict[str, object]]: + return [] + def get_module_state(self) -> str: return "IDLE" diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index 1c12ee0cb8..ab3b1803df 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -25,9 +25,14 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") -from dimos.manipulation.visualization.types import RobotInfo, TargetEvaluation, TargetSetEvaluation +from dimos.manipulation.planning.spec.enums import IKStatus +from dimos.manipulation.planning.spec.models import ( + CollisionCheckResult, + ForwardKinematicsResult, + IKResult, +) +from dimos.manipulation.visualization.types import RobotInfo from dimos.manipulation.visualization.viser import scene as scene_module -from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.animation import ( GroupPreviewAnimation, PreviewAnimator, @@ -40,6 +45,7 @@ ACTIVE_GROUP_COLOR, INACTIVE_GROUP_COLOR, PRIMARY_ACTION_COLOR, + ViserModuleAccess, ViserPanelGui, ) from dimos.manipulation.visualization.viser.scene import ViserManipulationScene @@ -462,87 +468,97 @@ def get_state(self) -> str: def get_error(self) -> str: return str(getattr(self, "_error_message", "")) - def evaluate_joint_target(self, joints: JointState | None, robot_name: str) -> TargetEvaluation: + def get_current_joint_state(self, robot_name: str) -> JointState | None: robot_id = self.robot_id_for_name(robot_name) - if robot_id is None or joints is None: - return {"success": False, "status": "NO_ROBOT", "joint_state": None} world_monitor = getattr(self, "_world_monitor", None) - if world_monitor is None: - return {"success": False, "status": "UNAVAILABLE", "joint_state": None} - collision_free = world_monitor.is_state_valid(robot_id, joints) - return { - "success": True, - "status": "FEASIBLE" if collision_free else "COLLISION", - "message": "Target is collision-free" if collision_free else "Target is in collision", - "collision_free": collision_free, - "ee_pose": world_monitor.get_ee_pose(robot_id, joints), - "joint_state": joints, - } + if robot_id is None or world_monitor is None: + return None + return world_monitor.get_current_joint_state(robot_id) - def evaluate_pose_target( - self, _pose: Pose, _robot_name: str, *, check_collision: bool = True - ) -> TargetEvaluation: - return { - "success": False, - "joint_state": None, - "status": "UNAVAILABLE", - "message": "No fake pose IK", - "collision_free": False, - } + def check_collision( + self, target_joints: JointState, max_age: float = 1.0 + ) -> CollisionCheckResult: + del max_age + world_monitor = getattr(self, "_world_monitor", None) + if world_monitor is not None and hasattr(world_monitor, "check_collision"): + return world_monitor.check_collision(target_joints) + collision_free = True + if world_monitor is not None: + for robot_name in self.list_robots(): + robot_id = self.robot_id_for_name(robot_name) + if robot_id is not None: + collision_free = collision_free and world_monitor.is_state_valid( + robot_id, target_joints + ) + return CollisionCheckResult( + status="VALID" if collision_free else "COLLISION", + collision_free=collision_free, + message="Target is collision-free" if collision_free else "Target is in collision", + ) - def evaluate_pose_target_set( + def forward_kinematics( self, - pose_targets: dict[str, Pose], - auxiliary_groups: Sequence[str] = (), - seed: JointState | None = None, - check_collision: bool = True, - ) -> TargetSetEvaluation: - target = JointState({"name": ["arm/j1", "arm/j2"], "position": [0.1, 0.2]}) - return { - "success": True, - "status": "FEASIBLE", - "message": "Target collision check skipped" - if not check_collision - else "Target is collision-free", - "collision_free": True, - "target_joints": target, - "group_ids": tuple(pose_targets) + tuple(auxiliary_groups), - "group_poses": dict(pose_targets), - } + group_id: str, + target_joints: JointState | None = None, + max_age: float = 1.0, + ) -> ForwardKinematicsResult: + del max_age + robot_name = group_id.split(":", 1)[0].split("/", 1)[0] + robot_id = self.robot_id_for_name(robot_name) + world_monitor = getattr(self, "_world_monitor", None) + pose = None + if world_monitor is not None and robot_id is not None: + if hasattr(world_monitor, "get_group_pose"): + pose = world_monitor.get_group_pose(group_id, target_joints) + else: + pose = world_monitor.get_ee_pose(robot_id, target_joints) + return ForwardKinematicsResult( + status="VALID", pose=pose, message="Forward kinematics solved" + ) - def evaluate_joint_target_set( - self, joint_targets: dict[str, JointState] - ) -> TargetSetEvaluation: - if not joint_targets: - return {"success": False, "status": "INVALID", "target_joints": None} + def _current_values_by_name(self, robot_name: str) -> dict[str, float]: + current = self.get_current_joint_state(robot_name) + if current is None: + return {} + values: dict[str, float] = {} + for name, position in zip(current.name, current.position, strict=False): + name_str = str(name) + values[name_str] = float(position) + if "/" in name_str: + values[name_str.rsplit("/", 1)[1]] = float(position) + else: + values[f"{robot_name}/{name_str}"] = float(position) + return values + + def inverse_kinematics( + self, + pose_targets: dict[str, PoseStamped], + auxiliary_group_ids: Sequence[str] = (), + seed: JointState | None = None, + ) -> IKResult: + del seed + group_ids = tuple(pose_targets) + tuple(auxiliary_group_ids) names: list[str] = [] positions: list[float] = [] - collision_free = True - group_poses = {} - world_monitor = getattr(self, "_world_monitor", None) - for group_id, target in joint_targets.items(): - robot_name = group_id.split(":", 1)[0] - robot_id = self.robot_id_for_name(robot_name) - names.extend(target.name) - positions.extend(float(value) for value in target.position) - if world_monitor is not None and robot_id is not None: - collision_free = collision_free and world_monitor.is_state_valid(robot_id, target) - group_poses[group_id] = world_monitor.get_ee_pose(robot_id, target) - return { - "success": True, - "status": "FEASIBLE" if collision_free else "COLLISION", - "message": "Target is collision-free" if collision_free else "Target is in collision", - "collision_free": collision_free, - "target_joints": JointState({"name": names, "position": positions}), - "group_ids": tuple(joint_targets), - "group_poses": group_poses, - } + for group_id in group_ids or (DEFAULT_GROUP_ID,): + robot_name = group_id.split(":", 1)[0].split("/", 1)[0] + config = self.get_robot_config(robot_name) + if config is None: + continue + for joint_name in config.joint_names: + names.append(f"{robot_name}/{joint_name}") + positions.append(0.1 + 0.1 * len(positions)) + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState({"name": names, "position": positions}), + message="Target is collision-free", + ) def plan_to_joint_targets(self, _joint_targets: dict[str, JointState]) -> bool: return True -def make_adapter_with_robot() -> InProcessViserAdapter: +def make_adapter_with_robot() -> ViserModuleAccess: current = FakeJointState(["j1", "j2"], position=[0.3, 0.4]) config = make_robot_config( name="arm", @@ -564,10 +580,7 @@ def make_adapter_with_robot() -> InProcessViserAdapter: _error_message="", _world_monitor=world_monitor, ) - return InProcessViserAdapter( - world_monitor=world_monitor, - manipulation_module=module, - ) + return ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) @pytest.fixture @@ -577,7 +590,7 @@ def make_panel() -> Iterator[Callable[..., ViserPanelGui]]: def _make( server: FakeGuiServer | FakeServer, - adapter: InProcessViserAdapter, + adapter: ViserModuleAccess, config: ViserVisualizationConfig | None = None, scene: ViserManipulationScene | None = None, ) -> ViserPanelGui: @@ -1153,97 +1166,6 @@ def test_joint_path_frame_edge_cases_and_empty_animation() -> None: assert sleep_calls == [] -def test_adapter_copies_joint_state_and_delegates_to_module() -> None: - copied = FakeJointState(["j1"], position=[1.0], velocity=[2.0], effort=[3.0]) - module = FakeManipulationModule( - _robots={"arm": ("robot-1", SimpleNamespace(), None)}, - plan_to_pose=lambda pose, robot_name=None: (pose, robot_name), - plan_to_joints=lambda joints, robot_name=None: (joints, robot_name), - preview_plan=lambda robot_name=None: robot_name, - execute=lambda robot_name=None: robot_name, - cancel=lambda: True, - clear_planned_path=lambda: True, - ) - world_monitor = SimpleNamespace( - get_current_joint_state=lambda robot_id: copied, - is_state_stale=lambda robot_id, max_age=1.0: False, - is_state_valid=lambda robot_id, joint_state: True, - get_ee_pose=lambda robot_id, joint_state=None: (robot_id, joint_state), - ) - module._world_monitor = world_monitor - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) - - current = adapter.get_current_joint_state("arm") - assert current is not copied - assert current.name is not copied.name - - assert adapter.plan_to_pose("pose", "arm") == ("pose", "arm") - assert adapter.preview_plan("arm") == "arm" - assert adapter.evaluate_joint_target(copied, "arm")["status"] == "FEASIBLE" - - -def test_adapter_evaluate_joint_target_uses_world_monitor_and_copies_input() -> None: - original = FakeJointState(["arm/j1", "j2"], position=[1.0, 2.0]) - seen = {} - - def is_state_valid(robot_id, joint_state) -> bool: - seen["robot_id"] = robot_id - seen["joint_state"] = joint_state - return True - - world_monitor = SimpleNamespace( - get_current_joint_state=lambda robot_id: None, - is_state_stale=lambda robot_id, max_age=1.0: False, - is_state_valid=is_state_valid, - get_ee_pose=lambda robot_id, joint_state=None: (robot_id, joint_state), - ) - module = FakeManipulationModule( - _robots={"arm": ("robot-1", SimpleNamespace(), None)}, - _world_monitor=world_monitor, - ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) - - result = adapter.evaluate_joint_target(original, "arm") - - assert result["success"] is True - assert result["status"] == "FEASIBLE" - assert seen["robot_id"] == "robot-1" - assert seen["joint_state"] is not original - assert seen["joint_state"].name == ["arm/j1", "j2"] - assert seen["joint_state"].position == [1.0, 2.0] - - -def test_obstacle_collision_marks_joint_target_infeasible() -> None: - obstacle = SimpleNamespace(name="blocking_box", blocked_joint_min=0.5) - - def is_state_valid(robot_id, joint_state) -> bool: - return bool(joint_state.position[0] < obstacle.blocked_joint_min) - - world_monitor = SimpleNamespace( - get_current_joint_state=lambda robot_id: FakeJointState(["j1"], position=[0.0]), - is_state_stale=lambda robot_id, max_age=1.0: False, - is_state_valid=is_state_valid, - get_ee_pose=lambda robot_id, joint_state=None: SimpleNamespace( - position=SimpleNamespace(x=0.0, y=0.0, z=0.0) - ), - ) - module = FakeManipulationModule( - _robots={"arm": ("robot-1", SimpleNamespace(joint_names=["j1"]), None)}, - _world_monitor=world_monitor, - ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) - - free = adapter.evaluate_joint_target(FakeJointState(["j1"], position=[0.25]), "arm") - colliding = adapter.evaluate_joint_target(FakeJointState(["j1"], position=[0.75]), "arm") - - assert free["success"] is True - assert free["status"] == "FEASIBLE" - assert free["collision_free"] is True - assert colliding["success"] is True - assert colliding["status"] == "COLLISION" - assert colliding["collision_free"] is False - - def test_scene_registers_goal_robot_coloring_and_updates_visibility() -> None: server = FakeServer() scene = ViserManipulationScene( @@ -1364,7 +1286,7 @@ def test_gui_initializes_pose_selector_to_current_ee_pose( is_state_stale=lambda robot_id, max_age=1.0: False, get_ee_pose=lambda robot_id, joint_state=None: current_pose, ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) scene = ViserManipulationScene( FakeTransformServer(), lambda *args, **kwargs: FakeViserUrdfWithMeshes(), preview_fps=10.0 ) @@ -1390,7 +1312,7 @@ def test_gui_removes_pose_selector_when_group_is_deselected( is_state_stale=lambda robot_id, max_age=1.0: False, get_ee_pose=lambda robot_id, joint_state=None: current_pose, ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) scene = ViserManipulationScene( FakeTransformServer(), lambda *args, **kwargs: FakeViserUrdfWithMeshes(), preview_fps=10.0 ) @@ -1424,7 +1346,7 @@ def test_gui_group_selector_derives_primary_and_auxiliary_groups( {"position": [0.0, 0.0, 0.0], "orientation": [0.0, 0.0, 0.0, 1.0]} ), ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) target_controls = [] scene = SimpleNamespace( has_reference_grid=lambda: False, @@ -1478,7 +1400,7 @@ def test_gui_target_ghost_visibility_follows_active_selected_groups( {"position": [0.0, 0.0, 0.0], "orientation": [0.0, 0.0, 0.0, 1.0]} ), ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) active_updates = [] scene = SimpleNamespace( has_reference_grid=lambda: False, @@ -1514,7 +1436,7 @@ def test_gui_preset_dropdown_and_controls_include_init_home_current_and_callback is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: None, ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) gui = make_panel(FakeGuiServer(), adapter) assert gui._handles["preset"].options == ["Select preset...", "Init", "Current", "Home"] assert list(gui._joint_sliders) == ["arm/j1", "arm/j2"] @@ -1538,7 +1460,7 @@ def test_gui_rebuilding_joint_sliders_removes_stale_viser_handles( is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: None, ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) server = FakeGuiServer() gui = make_panel(server, adapter) stale_sliders = list(server.sliders) @@ -1586,7 +1508,7 @@ def test_panel_execution_is_gated_by_default_and_refresh_updates_robot_controls( is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: None, ) - adapter = InProcessViserAdapter( + adapter = ViserModuleAccess( world_monitor=world_monitor, manipulation_module=module, ) @@ -1614,7 +1536,7 @@ def test_gui_moves_joint_target_immediately_and_stores_evaluated_joint_solution( is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: target_pose, ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) target_updates = [] target_pose_updates = [] scene = SimpleNamespace( @@ -1686,7 +1608,7 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: None, ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) target_joint_updates = [] target_pose_updates = [] scene = SimpleNamespace( @@ -1740,7 +1662,7 @@ def test_gui_can_disable_collision_check_for_cartesian_target_evaluation( {"position": [0.0, 0.0, 0.0], "orientation": [0.0, 0.0, 0.0, 1.0]} ), ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) scene = SimpleNamespace( has_reference_grid=lambda: False, ensure_target_controls=lambda *args: None, @@ -1788,7 +1710,7 @@ def test_gui_collision_evaluation_marks_target_infeasible_and_colors_scene( ), ) module._world_monitor = world_monitor - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) visual_states = [] scene = SimpleNamespace( has_reference_grid=lambda: False, @@ -1800,7 +1722,9 @@ def test_gui_collision_evaluation_marks_target_infeasible_and_colors_scene( gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) request = TargetEvaluationRequest(sequence_id=1, source="joints", group_ids=(DEFAULT_GROUP_ID,)) gui.state.latest_sequence_id = 1 - result = adapter.evaluate_joint_target(FakeJointState(["j1"], position=[1.0]), "arm") + result = adapter.evaluate_joint_target_set( + {DEFAULT_GROUP_ID: FakeJointState(["arm/j1"], position=[1.0])} + ) gui._apply_target_evaluation_result(request, result) @@ -1834,7 +1758,7 @@ def test_gui_safe_execute_requires_fresh_matching_plan_and_clear_resets_path( position=SimpleNamespace(x=0.0, y=0.0, z=0.0) ), ) - adapter = InProcessViserAdapter(world_monitor=world_monitor, manipulation_module=module) + adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) gui = make_panel( FakeGuiServer(), adapter, @@ -1857,6 +1781,7 @@ def test_gui_safe_execute_requires_fresh_matching_plan_and_clear_resets_path( start_joints_snapshot={DEFAULT_GROUP_ID: [1.2]}, planned_path=planned, ) + gui.state.target_joints = FakeJointState(["arm/j1"], position=[2.0]) gui._submit_execute() assert executed == [] assert "Cannot execute" in gui.state.error diff --git a/dimos/manipulation/visualization/viser/test_visualizer_lifecycle.py b/dimos/manipulation/visualization/viser/test_visualizer_lifecycle.py index bb7ffd8d47..36977ce313 100644 --- a/dimos/manipulation/visualization/viser/test_visualizer_lifecycle.py +++ b/dimos/manipulation/visualization/viser/test_visualizer_lifecycle.py @@ -28,7 +28,6 @@ runtime as runtime_module, visualizer as visualizer_module, ) -from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.animation import GroupPreviewAnimation from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig from dimos.manipulation.visualization.viser.runtime import ViserRuntime @@ -123,10 +122,12 @@ class FakeGui: def __init__( self, server: FakeServer, - adapter: InProcessViserAdapter, + world_monitor: object, + manipulation_module: object, config: ViserVisualizationConfig, scene: FakeScene, ) -> None: + del world_monitor, manipulation_module, config, scene calls.append(("create", "gui")) def start(self) -> None: @@ -201,10 +202,12 @@ class FakeGui: def __init__( self, server: FakeServer, - adapter: InProcessViserAdapter, + world_monitor: object, + manipulation_module: object, config: ViserVisualizationConfig, scene: FakeScene, ) -> None: + del world_monitor, manipulation_module, config, scene pass def start(self) -> None: @@ -307,10 +310,12 @@ class FailingGui: def __init__( self, server: FakeServer, - adapter: InProcessViserAdapter, + world_monitor: object, + manipulation_module: object, config: ViserVisualizationConfig, scene: FakeScene, ) -> None: + del world_monitor, manipulation_module, config, scene pass def start(self) -> None: diff --git a/dimos/manipulation/visualization/viser/visualizer.py b/dimos/manipulation/visualization/viser/visualizer.py index 4d394c9294..62c7c2cf45 100644 --- a/dimos/manipulation/visualization/viser/visualizer.py +++ b/dimos/manipulation/visualization/viser/visualizer.py @@ -19,7 +19,6 @@ from typing import TYPE_CHECKING from dimos.manipulation.planning.groups.identifiers import make_global_joint_name -from dimos.manipulation.visualization.viser.adapter import InProcessViserAdapter from dimos.manipulation.visualization.viser.animation import ( GroupPreviewAnimation, PreviewTrack, @@ -75,7 +74,6 @@ def __init__( self.config = config or ViserVisualizationConfig() self._runtime: ViserRuntime | None = None self._server: ViserServer | None = None - self._adapter: InProcessViserAdapter | None = None self._scene: ViserManipulationScene | None = None self._gui: ViserPanelGui | None = None self._closed = False @@ -89,17 +87,19 @@ def _ensure_started(self) -> None: try: server = runtime.start() apply_dimos_theme(server) - adapter = InProcessViserAdapter( - world_monitor=self._world_monitor, - manipulation_module=self._manipulation_module, - ) scene = ViserManipulationScene( server, ViserUrdf, preview_fps=self.config.preview_fps, ) gui = ( - ViserPanelGui(server, adapter, self.config, scene) + ViserPanelGui( + server, + self._world_monitor, + self._manipulation_module, + self.config, + scene, + ) if self.config.panel_enabled else None ) @@ -116,14 +116,12 @@ def _ensure_started(self) -> None: runtime.close() self._runtime = None self._server = None - self._adapter = None self._scene = None self._gui = None self._closed = True raise self._runtime = runtime self._server = server - self._adapter = adapter self._scene = scene self._gui = gui self._closed = False @@ -153,10 +151,17 @@ def publish_visualization(self, ctx: None = None) -> None: if self._closed: return self._ensure_started() - if self._adapter is None or self._scene is None: + if self._scene is None: return - for _robot_name, robot_id, _config in self._adapter.robot_items(): - current = self._adapter.get_current_joint_state(_robot_name) + for robot_name, robot_id, _config in self._manipulation_module.robot_items(): + get_current_joint_state = getattr( + self._manipulation_module, "get_current_joint_state", None + ) + current = ( + get_current_joint_state(robot_name) + if callable(get_current_joint_state) + else self._world_monitor.get_current_joint_state(robot_id) + ) self._scene.update_current_robot(str(robot_id), current) if self._gui is not None: self._gui.refresh() @@ -181,21 +186,28 @@ def animate_plan(self, plan: GeneratedPlan, duration: float = 3.0) -> None: if self._closed: return self._ensure_started() - if self._adapter is None or self._scene is None: + if self._scene is None: return preview = self._build_group_preview_animation(plan) if preview is not None: self._scene.animate_preview(preview, duration) def _build_group_preview_animation(self, plan: GeneratedPlan) -> GroupPreviewAnimation | None: - if self._adapter is None: - return None selection = self._world_monitor.planning_groups.select(plan.group_ids) tracks: list[PreviewTrack] = [] for robot_name in selection.robot_names: - robot_id = self._adapter.robot_id_for_name(robot_name) - config = self._adapter.get_robot_config(robot_name) - current = self._adapter.get_current_joint_state(robot_name) + robot_id = self._manipulation_module.robot_id_for_name(robot_name) + config = self._manipulation_module.get_robot_config(robot_name) + get_current_joint_state = getattr( + self._manipulation_module, "get_current_joint_state", None + ) + current = ( + get_current_joint_state(robot_name) + if callable(get_current_joint_state) + else self._world_monitor.get_current_joint_state(robot_id) + if robot_id is not None + else None + ) if robot_id is None or config is None or current is None: logger.warning( "Cannot build group preview for robot '%s': missing id, config, or state", @@ -221,12 +233,10 @@ def _build_group_preview_animation(self, plan: GeneratedPlan) -> GroupPreviewAni return GroupPreviewAnimation(group_ids=plan.group_ids, tracks=tuple(tracks)) def _robot_ids_for_groups(self, group_ids: Sequence[PlanningGroupID]) -> list[str]: - if self._adapter is None: - return [] selection = self._world_monitor.planning_groups.select(group_ids) robot_ids: list[str] = [] for robot_name in selection.robot_names: - robot_id = self._adapter.robot_id_for_name(robot_name) + robot_id = self._manipulation_module.robot_id_for_name(robot_name) if robot_id is not None: robot_ids.append(str(robot_id)) return robot_ids @@ -300,7 +310,6 @@ def close(self) -> None: errors.append(e) self._runtime = None self._server = None - self._adapter = None self._scene = None self._gui = None if errors: From 0bcce304d295099c203f58fc1aae8c002684431d Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 19:02:41 -0700 Subject: [PATCH 061/110] refactor: remove viser module access adapter --- dimos/manipulation/visualization/viser/gui.py | 260 ++++++++------- .../visualization/viser/module_access.py | 295 ----------------- .../visualization/viser/panel_backend.py | 308 ++++++++++++++++++ .../visualization/viser/test_gui_status.py | 42 +-- .../viser/test_operation_worker.py | 25 +- .../viser/test_viser_visualization.py | 133 ++++---- 6 files changed, 560 insertions(+), 503 deletions(-) delete mode 100644 dimos/manipulation/visualization/viser/module_access.py create mode 100644 dimos/manipulation/visualization/viser/panel_backend.py diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 57e5965f61..630a8dfae2 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -14,16 +14,33 @@ from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, TypeAlias, cast -from dimos.manipulation.planning.spec.models import PlanningGroupID +from dimos.manipulation.planning.groups.models import PlanningGroup +from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName from dimos.manipulation.visualization.types import ( PlanningGroupInfo, + RobotInfo, TargetEvaluation, TargetSetEvaluation, ) from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig -from dimos.manipulation.visualization.viser.module_access import ViserModuleAccess +from dimos.manipulation.visualization.viser.panel_backend import ( + copy_joint_state, + evaluate_joint_target_set, + evaluate_pose_target_set, + feasibility_status, + get_current_joint_state, + get_ee_pose, + group_display_name, + group_selector_color, + is_state_stale, + joint_values_by_name, + list_planning_groups, + normalize_robot_info, + pose_from_transform_values, +) from dimos.manipulation.visualization.viser.runtime import VISER_INSTALL_HINT from dimos.manipulation.visualization.viser.scene import ViserManipulationScene from dimos.manipulation.visualization.viser.state import ( @@ -88,25 +105,16 @@ class ViserPanelGui: def __init__( self, server: ViserServer, - world_monitor: WorldMonitor | ViserModuleAccess, - manipulation_module: ManipulationModule | ViserVisualizationConfig, - config: ViserVisualizationConfig | ViserManipulationScene | None = None, + world_monitor: WorldMonitor, + manipulation_module: ManipulationModule, + config: ViserVisualizationConfig, scene: ViserManipulationScene | None = None, ) -> None: self.server = server - if isinstance(manipulation_module, ViserVisualizationConfig): - self.adapter = cast("ViserModuleAccess", world_monitor) - self.config = manipulation_module - self.scene = cast("ViserManipulationScene | None", config) - else: - assert not isinstance(manipulation_module, ViserVisualizationConfig) - assert isinstance(config, ViserVisualizationConfig) - self.adapter = ViserModuleAccess( - cast("WorldMonitor", world_monitor), - cast("ManipulationModule", manipulation_module), - ) - self.config = config - self.scene = scene + self.world_monitor = world_monitor + self.manipulation_module = manipulation_module + self.config = config + self.scene = scene self.state = PanelState(runtime=PanelRuntime.STARTING) self._closed = False self._operation_sequence_id = 0 @@ -151,8 +159,8 @@ def close(self) -> None: def refresh(self) -> None: if self._closed: return - robots = self.adapter.list_robots() - groups = self.adapter.list_planning_groups() + robots = self._list_robots() + groups = self._list_planning_groups() self.state.backend_status = ( BackendConnectionStatus.READY if robots else BackendConnectionStatus.WAITING_FOR_ROBOT ) @@ -175,6 +183,73 @@ def refresh(self) -> None: self._update_target_summary() self._update_control_state() + def _list_robots(self) -> list[RobotName]: + return list(self.manipulation_module.list_robots()) + + def _list_planning_groups(self) -> list[PlanningGroupInfo]: + return list_planning_groups(self.manipulation_module) + + def _get_robot_info(self, robot_name: RobotName) -> RobotInfo | None: + return normalize_robot_info( + cast("RobotInfo | None", self.manipulation_module.get_robot_info(robot_name)) + ) + + def _get_init_joints(self, robot_name: RobotName) -> JointState | None: + return copy_joint_state(self.manipulation_module.get_init_joints(robot_name)) + + def _get_current_joint_state(self, robot_name: RobotName) -> JointState | None: + return get_current_joint_state( + self.world_monitor, + self.manipulation_module, + robot_name, + ) + + def _is_state_stale(self, robot_name: RobotName, max_age: float = 1.0) -> bool: + return is_state_stale( + self.world_monitor, + self.manipulation_module, + robot_name, + max_age, + ) + + def _get_ee_pose( + self, robot_name: RobotName, joint_state: JointState | None = None + ) -> Pose | None: + return get_ee_pose( + self.world_monitor, + self.manipulation_module, + self._list_planning_groups(), + robot_name, + joint_state, + ) + + def _get_module_state(self) -> str: + return str(self.manipulation_module.get_state()) + + def _reset(self) -> bool: + result = self.manipulation_module.reset() + return result if isinstance(result, bool) else result.is_success() + + def _evaluate_joint_target_set( + self, joint_targets: dict[PlanningGroupID, JointState] + ) -> TargetSetEvaluation: + return evaluate_joint_target_set(self.manipulation_module, joint_targets) + + def _evaluate_pose_target_set( + self, + pose_targets: dict[PlanningGroupID, Pose], + auxiliary_groups: tuple[PlanningGroupID, ...] = (), + seed: JointState | None = None, + check_collision: bool = True, + ) -> TargetSetEvaluation: + return evaluate_pose_target_set( + self.manipulation_module, + pose_targets, + auxiliary_groups=auxiliary_groups, + seed=seed, + check_collision=check_collision, + ) + def _build(self) -> None: gui = self.server.gui folder = gui.add_folder("Manipulation Panel", expand_by_default=True) @@ -188,7 +263,7 @@ def _build_panel_controls(self, gui: GuiApi) -> None: self._handles["planning_groups_heading"] = gui.add_markdown( "### Planning Groups\nActive MoveIt group for pose goal, planning, and joint edits." ) - self._sync_group_selector(self.adapter.list_planning_groups()) + self._sync_group_selector(self._list_planning_groups()) self._handles["target_heading"] = gui.add_markdown("### Target") preset_dropdown = gui.add_dropdown( "Preset", @@ -243,14 +318,14 @@ def _refresh_selected_robot_state(self) -> None: self.state.robot_info = None self.state.current_joints = None self.state.current_ee_pose = None - self.state.manipulation_state = self.adapter.get_module_state() + self.state.manipulation_state = self._get_module_state() return - self.state.robot_info = self.adapter.get_robot_info(robot_name) - current = self.adapter.get_current_joint_state(robot_name) + self.state.robot_info = self._get_robot_info(robot_name) + current = self._get_current_joint_state(robot_name) self.state.current_joints = list(current.position) if current is not None else None - self.state.current_ee_pose = self.adapter.get_ee_pose(robot_name) - self.state.manipulation_state = self.adapter.get_module_state() - adapter_error = self.adapter.get_error() + self.state.current_ee_pose = self._get_ee_pose(robot_name) + self.state.manipulation_state = self._get_module_state() + adapter_error = self.manipulation_module.get_error() if adapter_error: self.state.error = adapter_error @@ -315,9 +390,9 @@ def _build_joint_slider_handles(self, gui: GuiApi) -> None: group = groups.get(group_id) if group is None: continue - config = self.adapter.get_robot_config(str(group["robot_name"])) - current = self.adapter.get_current_joint_state(str(group["robot_name"])) - current_by_name = self._joint_values_by_name(str(group["robot_name"]), current) + config = self.manipulation_module.get_robot_config(str(group["robot_name"])) + current = self._get_current_joint_state(str(group["robot_name"])) + current_by_name = joint_values_by_name(str(group["robot_name"]), current) joint_limits_lower = config.joint_limits_lower if config is not None else None joint_limits_upper = config.joint_limits_upper if config is not None else None for index, (global_name, local_name) in enumerate( @@ -412,11 +487,13 @@ def _sync_group_selector(self, groups: list[PlanningGroupInfo]) -> None: seen_keys.add(key) handle = self._handles.get(key) is_selected = group_id in selected - label = self._group_selector_label(group, selected=is_selected) + label = group_display_name(group) if handle is None: handle = self.server.gui.add_button( label, - color=self._group_selector_color(is_selected), + color=group_selector_color( + is_selected, ACTIVE_GROUP_COLOR, INACTIVE_GROUP_COLOR + ), hint="Click to toggle this planning group in the target set.", ) handle.on_click(lambda _event, gid=group_id: self._toggle_group_selected(gid)) @@ -424,7 +501,9 @@ def _sync_group_selector(self, groups: list[PlanningGroupInfo]) -> None: else: self._set_optional_handle_attr(handle, "label", label) self._set_optional_handle_attr( - handle, "color", self._group_selector_color(is_selected) + handle, + "color", + group_selector_color(is_selected, ACTIVE_GROUP_COLOR, INACTIVE_GROUP_COLOR), ) for key in [key for key in self._handles if key.startswith("group:")]: @@ -434,21 +513,6 @@ def _sync_group_selector(self, groups: list[PlanningGroupInfo]) -> None: if callable(remove): remove() - @staticmethod - def _group_selector_label(group: PlanningGroupInfo, *, selected: bool = False) -> str: - _ = selected - return ViserPanelGui._group_display_name(group) - - @staticmethod - def _group_selector_color(selected: bool) -> tuple[int, int, int] | None: - return ACTIVE_GROUP_COLOR if selected else INACTIVE_GROUP_COLOR - - @staticmethod - def _group_display_name(group: PlanningGroupInfo) -> str: - robot_name = str(group["robot_name"]) - group_name = str(group["name"]) - return robot_name if group_name == "manipulator" else f"{robot_name} {group_name}" - def _set_group_selected(self, group_id: PlanningGroupID, selected: bool) -> None: current = list(self.state.selected_group_ids) if selected and group_id not in current: @@ -467,7 +531,7 @@ def _toggle_group_selected(self, group_id: PlanningGroupID) -> None: self._set_group_selected(group_id, group_id not in self.state.selected_group_ids) def _select_all_manipulators(self) -> None: - groups = self.adapter.list_planning_groups() + groups = self._list_planning_groups() manipulator_groups = [ str(group["id"]) for group in groups if str(group["name"]) == "manipulator" ] @@ -492,7 +556,7 @@ def _clear_group_selection(self) -> None: self.refresh() def _group_info_by_id(self) -> dict[PlanningGroupID, PlanningGroupInfo]: - return {str(group["id"]): group for group in self.adapter.list_planning_groups()} + return {str(group["id"]): group for group in self._list_planning_groups()} def _sync_selected_robot_from_groups(self) -> None: groups = self._group_info_by_id() @@ -548,10 +612,10 @@ def _initialize_selected_group_targets(self) -> None: group = groups.get(group_id) if group is None: continue - current = self.adapter.get_current_joint_state(str(group["robot_name"])) + current = self._get_current_joint_state(str(group["robot_name"])) if current is None: continue - current_by_name = self._joint_values_by_name(str(group["robot_name"]), current) + current_by_name = joint_values_by_name(str(group["robot_name"]), current) names = [str(name) for name in group["joint_names"]] local_names = [str(name) for name in group["local_joint_names"]] positions = [ @@ -562,7 +626,7 @@ def _initialize_selected_group_targets(self) -> None: {"name": names, "position": positions} ) if bool(group["has_pose_target"]) and group_id not in self.state.pose_targets: - pose = self.adapter.get_ee_pose(str(group["robot_name"])) + pose = self._get_ee_pose(str(group["robot_name"])) if pose is not None: self.state.pose_targets[group_id] = pose self.state.group_poses[group_id] = pose @@ -590,10 +654,10 @@ def _current_snapshot_by_group(self) -> dict[PlanningGroupID, list[float]]: group = groups.get(group_id) if group is None: continue - current = self.adapter.get_current_joint_state(str(group["robot_name"])) + current = self._get_current_joint_state(str(group["robot_name"])) if current is None: continue - current_by_name = self._joint_values_by_name(str(group["robot_name"]), current) + current_by_name = joint_values_by_name(str(group["robot_name"]), current) snapshot[group_id] = [ float( current_by_name.get(str(global_name), current_by_name.get(str(local_name), 0.0)) @@ -604,21 +668,6 @@ def _current_snapshot_by_group(self) -> dict[PlanningGroupID, list[float]]: ] return snapshot - @staticmethod - def _joint_values_by_name(robot_name: str, joint_state: JointState | None) -> dict[str, float]: - if joint_state is None: - return {} - values: dict[str, float] = {} - for name, position in zip(joint_state.name, joint_state.position, strict=False): - name_str = str(name) - position_float = float(position) - values[name_str] = position_float - if "/" in name_str: - values[name_str.rsplit("/", 1)[1]] = position_float - else: - values[f"{robot_name}/{name_str}"] = position_float - return values - def _sync_preset_dropdown(self) -> None: handle = self._handles.get("preset") if handle is None: @@ -626,13 +675,12 @@ def _sync_preset_dropdown(self) -> None: selected_robot_names = self._selected_robot_names() options = ["Select preset..."] if any( - self.adapter.get_init_joints(robot_name) is not None - for robot_name in selected_robot_names + self._get_init_joints(robot_name) is not None for robot_name in selected_robot_names ): options.append("Init") options.append("Current") if any( - (config := self.adapter.get_robot_config(robot_name)) is not None + (config := self.manipulation_module.get_robot_config(robot_name)) is not None and config.home_joints is not None for robot_name in selected_robot_names ): @@ -651,7 +699,7 @@ def _apply_preset(self, preset: str) -> None: return groups = [ group - for group in self.adapter.list_planning_groups() + for group in self._list_planning_groups() if group["id"] in self.state.selected_group_ids ] for group in groups: @@ -682,7 +730,7 @@ def _selected_robot_names(self) -> tuple[str, ...]: def _preset_values_by_name(self, preset: str, robot_name: str) -> dict[str, float]: if preset == "Current": - current = self.adapter.get_current_joint_state(robot_name) + current = self._get_current_joint_state(robot_name) if current is None: return {} return { @@ -690,14 +738,14 @@ def _preset_values_by_name(self, preset: str, robot_name: str) -> dict[str, floa for name, value in zip(current.name, current.position, strict=False) } if preset == "Init": - init = self.adapter.get_init_joints(robot_name) + init = self._get_init_joints(robot_name) if init is None: return {} return { str(name): float(value) for name, value in zip(init.name, init.position, strict=False) } - config = self.adapter.get_robot_config(robot_name) + config = self.manipulation_module.get_robot_config(robot_name) if config is None: return {} return { @@ -729,9 +777,7 @@ def _on_transform_update( return if self._suppress_target_callbacks or group_id not in self.state.selected_group_ids: return - pose = self._pose_from_transform_target(target) - if pose is None: - return + pose = pose_from_transform_values(target.position, target.wxyz) self.state.cartesian_target = pose self.state.pose_targets[group_id] = pose sequence_id = self.state.next_sequence_id() @@ -775,8 +821,8 @@ def _move_joint_target_visuals(self) -> None: if group is None: continue robot_name = str(group["robot_name"]) - robot_id = self.adapter.robot_id_for_name(robot_name) - config = self.adapter.get_robot_config(robot_name) + robot_id = self.manipulation_module.robot_id_for_name(robot_name) + config = self.manipulation_module.get_robot_config(robot_name) if robot_id is None or config is None: continue local_positions = dict(zip(target.name, target.position, strict=False)) @@ -795,13 +841,13 @@ def _sync_target_ghost_visibility(self) -> None: group = groups.get(group_id) if group is None: continue - robot_id = self.adapter.robot_id_for_name(str(group["robot_name"])) + robot_id = self.manipulation_module.robot_id_for_name(str(group["robot_name"])) if robot_id is not None: active_robot_ids.add(str(robot_id)) set_target_active = getattr(self.scene, "set_target_active", None) if not callable(set_target_active): return - for _robot_name, robot_id, _config in self.adapter.robot_items(): + for _robot_name, robot_id, _config in self.manipulation_module.robot_items(): set_target_active(str(robot_id), str(robot_id) in active_robot_ids) def _handle_target_evaluation_request( @@ -810,7 +856,7 @@ def _handle_target_evaluation_request( if request.source == "cartesian": if not request.pose_targets: return {"success": False, "status": "INVALID", "message": "No pose target"} - return self.adapter.evaluate_pose_target_set( + return self._evaluate_pose_target_set( request.pose_targets, auxiliary_groups=request.auxiliary_group_ids, seed=self.state.last_valid_target_joints, @@ -818,7 +864,7 @@ def _handle_target_evaluation_request( ) if not request.joint_targets: return {"success": False, "status": "INVALID", "message": "No joint target"} - return self.adapter.evaluate_joint_target_set(request.joint_targets) + return self._evaluate_joint_target_set(request.joint_targets) def _apply_target_evaluation_result( self, request: TargetEvaluationRequest, result: TargetEvaluation | TargetSetEvaluation @@ -829,7 +875,9 @@ def _apply_target_evaluation_result( return collision_free = bool(result.get("collision_free", False)) success = bool(result.get("success", False)) - self.state.feasibility.status = self._feasibility_status(result, success, collision_free) + self.state.feasibility.status = feasibility_status( + str(result.get("status", "")), success, collision_free + ) self.state.feasibility.message = str(result.get("message", "")) self.state.target_status = ( TargetStatus.FEASIBLE if success and collision_free else TargetStatus.INFEASIBLE @@ -908,9 +956,7 @@ def _update_status_text(self) -> None: f"Target: `{self.state.target_status.value}` · Plan: `{self.state.plan_state.status.value}`", ] if self.state.selected_robot is not None: - status.append( - f"State stale: `{self.adapter.is_state_stale(self.state.selected_robot)}`" - ) + status.append(f"State stale: `{self._is_state_stale(self.state.selected_robot)}`") if current is not None: status.append(f"Current joints: `{[round(v, 3) for v in current]}`") if self.state.last_result: @@ -965,7 +1011,7 @@ def operation() -> None: return self.state.action_status = ActionStatus.RUNNING self.state.plan_state.status = PlanStatus.PLANNING - if self.state.manipulation_state == "FAULT" and not self.adapter.reset(): + if self.state.manipulation_state == "FAULT" and not self._reset(): self.state.plan_state.status = PlanStatus.FAILED self._finish_operation("reset=False", clear_error=False, operation_id=operation_id) return @@ -976,7 +1022,9 @@ def operation() -> None: "plan_to_joints=False", clear_error=False, operation_id=operation_id ) return - ok = self.adapter.plan_target_set(targets) + ok = self.manipulation_module.plan_to_joint_targets( + cast("Mapping[PlanningGroupID | PlanningGroup, JointState]", targets) + ) if not self._operation_is_current(operation_id): return if ok: @@ -1009,7 +1057,7 @@ def operation() -> None: if not self._operation_is_current(operation_id): return self.state.action_status = ActionStatus.PREVIEWING - ok = self.adapter.preview_plan() + ok = self.manipulation_module.preview_plan() self._finish_operation(f"preview={ok}", operation_id=operation_id) self._operation_worker.submit( @@ -1038,7 +1086,7 @@ def operation() -> None: return self.state.action_status = ActionStatus.EXECUTING self.state.plan_state.status = PlanStatus.EXECUTING - ok = self.adapter.execute() + ok = self.manipulation_module.execute() if not self._operation_is_current(operation_id): return if not ok: @@ -1060,7 +1108,7 @@ def _submit_cancel(self) -> None: self._mark_cancelled_plan_state(cancelled_action) self._restart_operation_worker() try: - ok = self.adapter.cancel() + ok = self.manipulation_module.cancel() except Exception as e: self._set_operation_error(str(e), operation_id) return @@ -1089,7 +1137,7 @@ def operation() -> None: if not self._operation_is_current(operation_id): return self.state.action_status = ActionStatus.CLEARING_PLAN - ok = self.adapter.clear_planned_path() + ok = self.manipulation_module.clear_planned_path() if not self._operation_is_current(operation_id): return self.state.plan_state = PanelPlanState() @@ -1158,23 +1206,3 @@ def _set_visible(self, key: str, visible: bool) -> None: @staticmethod def _set_optional_handle_attr(handle: object, attr: str, value: object) -> None: setattr(handle, attr, value) - - def _pose_from_transform_target(self, target: TransformControlsHandle) -> Pose | None: - px, py, pz = (float(value) for value in target.position) - qw, qx, qy, qz = (float(value) for value in target.wxyz) - return Pose({"position": [px, py, pz], "orientation": [qx, qy, qz, qw]}) - - def _feasibility_status( - self, - result: TargetEvaluation | TargetSetEvaluation, - success: bool, - collision_free: bool, - ) -> FeasibilityStatus: - status = str(result.get("status", "")).upper() - if success and collision_free: - return FeasibilityStatus.FEASIBLE - if status in {"COLLISION", "COLLISION_AT_START", "COLLISION_AT_GOAL"}: - return FeasibilityStatus.COLLISION - if status in {"NO_SOLUTION", "SINGULARITY", "JOINT_LIMITS", "TIMEOUT"}: - return FeasibilityStatus.IK_FAILED - return FeasibilityStatus.INVALID diff --git a/dimos/manipulation/visualization/viser/module_access.py b/dimos/manipulation/visualization/viser/module_access.py deleted file mode 100644 index 2ca38863d2..0000000000 --- a/dimos/manipulation/visualization/viser/module_access.py +++ /dev/null @@ -1,295 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, cast - -from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName, WorldRobotID -from dimos.manipulation.visualization.types import ( - PlanningGroupInfo, - RobotInfo, - TargetSetEvaluation, -) -from dimos.msgs.geometry_msgs.Pose import Pose -from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped -from dimos.msgs.sensor_msgs.JointState import JointState - -if TYPE_CHECKING: - from dimos.manipulation.manipulation_module import ManipulationModule - from dimos.manipulation.planning.groups.models import PlanningGroup - from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor - from dimos.manipulation.planning.spec.config import RobotModelConfig - - -def copy_joint_state(joint_state: JointState | None) -> JointState | None: - """Make a local copy of a JointState-like message for rendering.""" - return None if joint_state is None else JointState(joint_state) - - -class ViserModuleAccess: - """Direct in-process access from Viser UI code to ManipulationModule primitives.""" - - def __init__( - self, world_monitor: WorldMonitor, manipulation_module: ManipulationModule - ) -> None: - self._world_monitor = world_monitor - self._module = manipulation_module - - def list_robots(self) -> list[RobotName]: - return list(self._module.list_robots()) - - def robot_items(self) -> list[tuple[RobotName, WorldRobotID, RobotModelConfig]]: - return self._module.robot_items() - - def robot_id_for_name(self, robot_name: RobotName) -> WorldRobotID | None: - return self._module.robot_id_for_name(robot_name) - - def robot_name_for_id(self, robot_id: WorldRobotID) -> RobotName | None: - return self._module.robot_name_for_id(robot_id) - - def get_robot_config(self, robot_name: RobotName) -> RobotModelConfig | None: - return self._module.get_robot_config(robot_name) - - def get_robot_info(self, robot_name: RobotName) -> RobotInfo | None: - info = self._module.get_robot_info(robot_name) - if info is None: - return None - return { - "name": str(info["name"]), - "world_robot_id": str(info["world_robot_id"]), - "joint_names": [str(name) for name in info["joint_names"]], - "end_effector_link": str(info["end_effector_link"]), - "base_link": str(info["base_link"]), - "max_velocity": float(info["max_velocity"]), - "max_acceleration": float(info["max_acceleration"]), - "has_joint_name_mapping": bool(info.get("has_joint_name_mapping", False)), - "coordinator_task_name": None - if info["coordinator_task_name"] is None - else str(info["coordinator_task_name"]), - "home_joints": None - if info["home_joints"] is None - else [float(value) for value in info["home_joints"]], - "pre_grasp_offset": float(info["pre_grasp_offset"]), - "init_joints": None - if info["init_joints"] is None - else [float(value) for value in info["init_joints"]], - "planning_groups": [ - { - "id": str(group["id"]), - "name": str(group["name"]), - "robot_name": str(info["name"]), - "joint_names": [str(name) for name in group["joint_names"]], - "local_joint_names": [str(name) for name in group["local_joint_names"]], - "base_link": str(group["base_link"]), - "tip_link": None if group["tip_link"] is None else str(group["tip_link"]), - "has_pose_target": bool(group["has_pose_target"]), - "source": str(group["source"]), - } - for group in info.get("planning_groups", []) - ], - } - - def list_planning_groups(self) -> list[PlanningGroupInfo]: - groups: list[PlanningGroupInfo] = [] - for robot_name in self.list_robots(): - info = self.get_robot_info(robot_name) - if info is not None: - groups.extend(info.get("planning_groups", [])) - return groups - - def get_init_joints(self, robot_name: RobotName) -> JointState | None: - return copy_joint_state(self._module.get_init_joints(robot_name)) - - def get_current_joint_state(self, robot_name: RobotName) -> JointState | None: - current = self._module.get_current_joint_state(robot_name) - if current is None: - robot_id = self.robot_id_for_name(robot_name) - if robot_id is not None: - current = self._world_monitor.get_current_joint_state(robot_id) - return copy_joint_state(current) - - def is_state_stale(self, robot_name: RobotName, max_age: float = 1.0) -> bool: - robot_id = self.robot_id_for_name(robot_name) - return True if robot_id is None else self._world_monitor.is_state_stale(robot_id, max_age) - - def get_ee_pose( - self, robot_name: RobotName, joint_state: JointState | None = None - ) -> PoseStamped | Pose | None: - group_id = self._primary_pose_group_id(robot_name) - if group_id is None: - return None - fk = self._module.forward_kinematics(group_id, copy_joint_state(joint_state)) - if fk.pose is not None: - return fk.pose - robot_id = self.robot_id_for_name(robot_name) - if robot_id is None: - return None - get_ee_pose = getattr(self._world_monitor, "get_ee_pose", None) - if callable(get_ee_pose): - return cast( - "PoseStamped | Pose | None", get_ee_pose(robot_id, copy_joint_state(joint_state)) - ) - return None - - def get_module_state(self) -> str: - return str(self._module.get_state()) - - def get_error(self) -> str: - return self._module.get_error() - - def reset(self) -> bool: - result = self._module.reset() - return result if isinstance(result, bool) else result.is_success() - - def plan_target_set(self, joint_targets: dict[PlanningGroupID, JointState]) -> bool: - return self._module.plan_to_joint_targets( - cast("dict[PlanningGroupID | PlanningGroup, JointState]", joint_targets) - ) - - def preview_plan(self, robot_name: RobotName | None = None) -> bool: - return self._module.preview_plan(robot_name=robot_name) - - def execute(self) -> bool: - return self._module.execute() - - def cancel(self) -> bool: - return self._module.cancel() - - def clear_planned_path(self) -> bool: - return self._module.clear_planned_path() - - def evaluate_joint_target_set( - self, joint_targets: Mapping[PlanningGroupID, JointState] - ) -> TargetSetEvaluation: - if not joint_targets: - return {"success": False, "status": "INVALID", "message": "No joint target"} - names: list[str] = [] - positions: list[float] = [] - for target in joint_targets.values(): - copied = copy_joint_state(target) - if copied is None: - continue - names.extend(str(name) for name in copied.name) - positions.extend(float(position) for position in copied.position) - return self._evaluate_global_target_set( - tuple(joint_targets), JointState({"name": names, "position": positions}) - ) - - def evaluate_pose_target_set( - self, - pose_targets: Mapping[PlanningGroupID, Pose | PoseStamped], - auxiliary_groups: Sequence[PlanningGroupID] = (), - seed: JointState | None = None, - check_collision: bool = True, - ) -> TargetSetEvaluation: - if not pose_targets: - return {"success": False, "status": "INVALID", "message": "No pose target"} - stamped_targets = { - group_id: self._stamped_pose(pose) for group_id, pose in pose_targets.items() - } - group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_groups))) - ik = self._module.inverse_kinematics( - pose_targets=stamped_targets, - auxiliary_group_ids=auxiliary_groups, - seed=copy_joint_state(seed), - ) - if not ik.is_success() or ik.joint_state is None: - return { - "success": False, - "status": ik.status.name, - "message": ik.message, - "collision_free": False, - "group_ids": group_ids, - "target_joints": None, - "position_error": ik.position_error, - "orientation_error": ik.orientation_error, - } - return self._evaluate_global_target_set( - group_ids, - ik.joint_state, - status=ik.status.name, - message=ik.message, - position_error=ik.position_error, - orientation_error=ik.orientation_error, - check_collision=check_collision, - ) - - def _evaluate_global_target_set( - self, - group_ids: tuple[PlanningGroupID, ...], - target_joints: JointState, - *, - status: str = "FEASIBLE", - message: str = "Target is collision-free", - position_error: float = 0.0, - orientation_error: float = 0.0, - check_collision: bool = True, - ) -> TargetSetEvaluation: - target = copy_joint_state(target_joints) - if target is None: - return {"success": False, "status": "INVALID", "message": "No target joints"} - collision = self._module.check_collision(target) if check_collision else None - collision_free = True if collision is None else collision.collision_free is True - diagnostics = { - group_id: "Target collision check skipped" if collision is None else collision.message - for group_id in group_ids - } - result_message = message - if collision is None: - result_message = "Target collision check skipped" - elif not collision_free: - result_message = collision.message - group_poses: dict[PlanningGroupID, PoseStamped | Pose | None] = {} - for group_id in group_ids: - fk = self._module.forward_kinematics(group_id, target) - if fk.pose is not None: - group_poses[group_id] = fk.pose - elif fk.status != "INVALID": - group_poses[group_id] = None - return { - "success": collision_free, - "status": status - if collision_free - else collision.status - if collision is not None - else "COLLISION", - "message": result_message, - "collision_free": collision_free, - "group_ids": group_ids, - "target_joints": target, - "group_diagnostics": diagnostics, - "group_poses": group_poses, - "position_error": position_error, - "orientation_error": orientation_error, - } - - def _primary_pose_group_id(self, robot_name: RobotName) -> PlanningGroupID | None: - for group in self.list_planning_groups(): - if str(group["robot_name"]) == robot_name and bool(group["has_pose_target"]): - return str(group["id"]) - return None - - @staticmethod - def _stamped_pose(pose: Pose | PoseStamped) -> PoseStamped: - if isinstance(pose, PoseStamped): - return pose - return PoseStamped(frame_id="world", position=pose.position, orientation=pose.orientation) - - @staticmethod - def joints_from_values(joint_names: Sequence[str], values: Sequence[float]) -> JointState: - return JointState( - {"name": list(joint_names), "position": [float(value) for value in values]} - ) diff --git a/dimos/manipulation/visualization/viser/panel_backend.py b/dimos/manipulation/visualization/viser/panel_backend.py new file mode 100644 index 0000000000..fa28371070 --- /dev/null +++ b/dimos/manipulation/visualization/viser/panel_backend.py @@ -0,0 +1,308 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, cast + +from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName +from dimos.manipulation.visualization.types import ( + PlanningGroupInfo, + RobotInfo, + TargetSetEvaluation, +) +from dimos.manipulation.visualization.viser.state import FeasibilityStatus +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.JointState import JointState + +if TYPE_CHECKING: + from dimos.manipulation.manipulation_module import ManipulationModule + from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor + + +def copy_joint_state(joint_state: JointState | None) -> JointState | None: + return None if joint_state is None else JointState(joint_state) + + +def normalize_robot_info(info: RobotInfo | None) -> RobotInfo | None: + if info is None: + return None + coordinator_task_name = info.get("coordinator_task_name") + home_joints = info.get("home_joints") + init_joints = info.get("init_joints") + robot_name = str(info.get("name", "")) + return { + "name": robot_name, + "world_robot_id": str(info.get("world_robot_id", "")), + "joint_names": [str(name) for name in info.get("joint_names", [])], + "end_effector_link": str(info.get("end_effector_link", "")), + "base_link": str(info.get("base_link", "")), + "max_velocity": float(info.get("max_velocity", 0.0)), + "max_acceleration": float(info.get("max_acceleration", 0.0)), + "has_joint_name_mapping": bool(info.get("has_joint_name_mapping", False)), + "coordinator_task_name": None + if coordinator_task_name is None + else str(coordinator_task_name), + "home_joints": None if home_joints is None else [float(value) for value in home_joints], + "pre_grasp_offset": float(info.get("pre_grasp_offset", 0.0)), + "init_joints": None if init_joints is None else [float(value) for value in init_joints], + "planning_groups": [ + { + "id": str(group["id"]), + "name": str(group["name"]), + "robot_name": robot_name, + "joint_names": [str(name) for name in group["joint_names"]], + "local_joint_names": [str(name) for name in group["local_joint_names"]], + "base_link": str(group["base_link"]), + "tip_link": None if group["tip_link"] is None else str(group["tip_link"]), + "has_pose_target": bool(group["has_pose_target"]), + "source": str(group["source"]), + } + for group in info.get("planning_groups", []) + ], + } + + +def list_planning_groups(manipulation_module: ManipulationModule) -> list[PlanningGroupInfo]: + groups: list[PlanningGroupInfo] = [] + for robot_name in manipulation_module.list_robots(): + info = normalize_robot_info( + cast("RobotInfo | None", manipulation_module.get_robot_info(robot_name)) + ) + if info is not None: + groups.extend(info.get("planning_groups", [])) + return groups + + +def get_current_joint_state( + world_monitor: WorldMonitor, + manipulation_module: ManipulationModule, + robot_name: RobotName, +) -> JointState | None: + current = manipulation_module.get_current_joint_state(robot_name) + if current is None: + robot_id = manipulation_module.robot_id_for_name(robot_name) + if robot_id is not None: + current = world_monitor.get_current_joint_state(robot_id) + return copy_joint_state(current) + + +def is_state_stale( + world_monitor: WorldMonitor, + manipulation_module: ManipulationModule, + robot_name: RobotName, + max_age: float = 1.0, +) -> bool: + robot_id = manipulation_module.robot_id_for_name(robot_name) + return True if robot_id is None else world_monitor.is_state_stale(robot_id, max_age) + + +def get_ee_pose( + world_monitor: WorldMonitor, + manipulation_module: ManipulationModule, + groups: Sequence[PlanningGroupInfo], + robot_name: RobotName, + joint_state: JointState | None = None, +) -> Pose | None: + group_id = primary_pose_group_id(groups, robot_name) + if group_id is None: + return None + copied = copy_joint_state(joint_state) + fk = manipulation_module.forward_kinematics(group_id, copied) + if fk.pose is not None: + return cast("Pose", fk.pose) + robot_id = manipulation_module.robot_id_for_name(robot_name) + if robot_id is None: + return None + get_world_ee_pose = getattr(world_monitor, "get_ee_pose", None) + if callable(get_world_ee_pose): + return cast("Pose | None", get_world_ee_pose(robot_id, copy_joint_state(joint_state))) + return None + + +def evaluate_joint_target_set( + manipulation_module: ManipulationModule, + joint_targets: Mapping[PlanningGroupID, JointState], +) -> TargetSetEvaluation: + if not joint_targets: + return {"success": False, "status": "INVALID", "message": "No joint target"} + names: list[str] = [] + positions: list[float] = [] + for target in joint_targets.values(): + copied = copy_joint_state(target) + if copied is None: + continue + names.extend(str(name) for name in copied.name) + positions.extend(float(position) for position in copied.position) + return evaluate_global_target_set( + manipulation_module, + tuple(joint_targets), + JointState({"name": names, "position": positions}), + ) + + +def evaluate_pose_target_set( + manipulation_module: ManipulationModule, + pose_targets: Mapping[PlanningGroupID, Pose], + auxiliary_groups: Sequence[PlanningGroupID] = (), + seed: JointState | None = None, + check_collision: bool = True, +) -> TargetSetEvaluation: + if not pose_targets: + return {"success": False, "status": "INVALID", "message": "No pose target"} + stamped_targets = {group_id: stamped_pose(pose) for group_id, pose in pose_targets.items()} + group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_groups))) + ik = manipulation_module.inverse_kinematics( + pose_targets=stamped_targets, + auxiliary_group_ids=auxiliary_groups, + seed=copy_joint_state(seed), + ) + if not ik.is_success() or ik.joint_state is None: + return { + "success": False, + "status": ik.status.name, + "message": ik.message, + "collision_free": False, + "group_ids": group_ids, + "target_joints": None, + "position_error": ik.position_error, + "orientation_error": ik.orientation_error, + } + return evaluate_global_target_set( + manipulation_module, + group_ids, + ik.joint_state, + status=ik.status.name, + message=ik.message, + position_error=ik.position_error, + orientation_error=ik.orientation_error, + check_collision=check_collision, + ) + + +def evaluate_global_target_set( + manipulation_module: ManipulationModule, + group_ids: tuple[PlanningGroupID, ...], + target_joints: JointState, + *, + status: str = "FEASIBLE", + message: str = "Target is collision-free", + position_error: float = 0.0, + orientation_error: float = 0.0, + check_collision: bool = True, +) -> TargetSetEvaluation: + target = copy_joint_state(target_joints) + if target is None: + return {"success": False, "status": "INVALID", "message": "No target joints"} + collision = manipulation_module.check_collision(target) if check_collision else None + collision_free = True if collision is None else collision.collision_free is True + diagnostics = { + group_id: "Target collision check skipped" if collision is None else collision.message + for group_id in group_ids + } + result_message = message + if collision is None: + result_message = "Target collision check skipped" + elif not collision_free: + result_message = collision.message + group_poses: dict[PlanningGroupID, Pose | None] = {} + for group_id in group_ids: + fk = manipulation_module.forward_kinematics(group_id, target) + if fk.pose is not None: + group_poses[group_id] = cast("Pose", fk.pose) + elif fk.status != "INVALID": + group_poses[group_id] = None + return { + "success": collision_free, + "status": status + if collision_free + else collision.status + if collision is not None + else "COLLISION", + "message": result_message, + "collision_free": collision_free, + "group_ids": group_ids, + "target_joints": target, + "group_diagnostics": diagnostics, + "group_poses": group_poses, + "position_error": position_error, + "orientation_error": orientation_error, + } + + +def primary_pose_group_id( + groups: Sequence[PlanningGroupInfo], robot_name: RobotName +) -> PlanningGroupID | None: + for group in groups: + if str(group["robot_name"]) == robot_name and bool(group["has_pose_target"]): + return str(group["id"]) + return None + + +def stamped_pose(pose: Pose | PoseStamped) -> PoseStamped: + if isinstance(pose, PoseStamped): + return pose + return PoseStamped(frame_id="world", position=pose.position, orientation=pose.orientation) + + +def joint_values_by_name(robot_name: str, joint_state: JointState | None) -> dict[str, float]: + if joint_state is None: + return {} + values: dict[str, float] = {} + for name, position in zip(joint_state.name, joint_state.position, strict=False): + name_str = str(name) + position_float = float(position) + values[name_str] = position_float + if "/" in name_str: + values[name_str.rsplit("/", 1)[1]] = position_float + else: + values[f"{robot_name}/{name_str}"] = position_float + return values + + +def pose_from_transform_values(position: Sequence[float], wxyz: Sequence[float]) -> Pose: + px, py, pz = (float(value) for value in position) + qw, qx, qy, qz = (float(value) for value in wxyz) + return Pose({"position": [px, py, pz], "orientation": [qx, qy, qz, qw]}) + + +def group_display_name(group: PlanningGroupInfo) -> str: + robot_name = str(group["robot_name"]) + group_name = str(group["name"]) + return robot_name if group_name == "manipulator" else f"{robot_name} {group_name}" + + +def group_selector_color( + selected: bool, + active_color: tuple[int, int, int], + inactive_color: tuple[int, int, int], +) -> tuple[int, int, int]: + return active_color if selected else inactive_color + + +def feasibility_status( + status: str, + success: bool, + collision_free: bool, +) -> FeasibilityStatus: + normalized = status.upper() + if success and collision_free: + return FeasibilityStatus.FEASIBLE + if normalized in {"COLLISION", "COLLISION_AT_START", "COLLISION_AT_GOAL"}: + return FeasibilityStatus.COLLISION + if normalized in {"NO_SOLUTION", "SINGULARITY", "JOINT_LIMITS", "TIMEOUT"}: + return FeasibilityStatus.IK_FAILED + return FeasibilityStatus.INVALID diff --git a/dimos/manipulation/visualization/viser/test_gui_status.py b/dimos/manipulation/visualization/viser/test_gui_status.py index 4ebff5e5c3..617f32a988 100644 --- a/dimos/manipulation/visualization/viser/test_gui_status.py +++ b/dimos/manipulation/visualization/viser/test_gui_status.py @@ -18,44 +18,28 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") -from dimos.manipulation.visualization.types import TargetEvaluation -from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig -from dimos.manipulation.visualization.viser.gui import ViserPanelGui +from dimos.manipulation.visualization.viser.panel_backend import feasibility_status from dimos.manipulation.visualization.viser.state import FeasibilityStatus -class StatusOnlyServer: - pass - - -class StatusOnlyAdapter: - pass - - @pytest.mark.parametrize( - ("result", "success", "collision_free", "expected"), + ("status", "success", "collision_free", "expected"), [ - ({"status": "FEASIBLE"}, True, True, FeasibilityStatus.FEASIBLE), - ({"status": "COLLISION"}, False, False, FeasibilityStatus.COLLISION), - ({"status": "COLLISION_AT_START"}, False, False, FeasibilityStatus.COLLISION), - ({"status": "COLLISION_AT_GOAL"}, False, False, FeasibilityStatus.COLLISION), - ({"status": "NO_SOLUTION"}, False, False, FeasibilityStatus.IK_FAILED), - ({"status": "SINGULARITY"}, False, False, FeasibilityStatus.IK_FAILED), - ({"status": "JOINT_LIMITS"}, False, False, FeasibilityStatus.IK_FAILED), - ({"status": "TIMEOUT"}, False, False, FeasibilityStatus.IK_FAILED), - ({"status": "IK_SUCCEEDED"}, False, False, FeasibilityStatus.INVALID), + ("FEASIBLE", True, True, FeasibilityStatus.FEASIBLE), + ("COLLISION", False, False, FeasibilityStatus.COLLISION), + ("COLLISION_AT_START", False, False, FeasibilityStatus.COLLISION), + ("COLLISION_AT_GOAL", False, False, FeasibilityStatus.COLLISION), + ("NO_SOLUTION", False, False, FeasibilityStatus.IK_FAILED), + ("SINGULARITY", False, False, FeasibilityStatus.IK_FAILED), + ("JOINT_LIMITS", False, False, FeasibilityStatus.IK_FAILED), + ("TIMEOUT", False, False, FeasibilityStatus.IK_FAILED), + ("IK_SUCCEEDED", False, False, FeasibilityStatus.INVALID), ], ) def test_gui_feasibility_status_uses_exact_status_mapping( - result: TargetEvaluation, + status: str, success: bool, collision_free: bool, expected: FeasibilityStatus, ) -> None: - gui = ViserPanelGui( - StatusOnlyServer(), - StatusOnlyAdapter(), - ViserVisualizationConfig(), - ) - - assert gui._feasibility_status(result, success, collision_free) == expected + assert feasibility_status(status, success, collision_free) == expected diff --git a/dimos/manipulation/visualization/viser/test_operation_worker.py b/dimos/manipulation/visualization/viser/test_operation_worker.py index 035fa6f2a7..3e6d34459a 100644 --- a/dimos/manipulation/visualization/viser/test_operation_worker.py +++ b/dimos/manipulation/visualization/viser/test_operation_worker.py @@ -41,6 +41,14 @@ class EmptyServer: pass +class EmptyWorldMonitor: + def get_current_joint_state(self, robot_id: str) -> None: + return None + + def is_state_stale(self, robot_id: str, max_age: float = 1.0) -> bool: + return False + + @dataclass class FakeStopOperationWorker(OperationWorker): stop_calls: list[float | None] @@ -112,10 +120,10 @@ def __init__(self) -> None: def list_robots(self) -> list[str]: return [] - def list_planning_groups(self) -> list[dict[str, object]]: - return [] + def robot_id_for_name(self, robot_name: str) -> str | None: + return None - def get_module_state(self) -> str: + def get_state(self) -> str: return "IDLE" def get_robot_info(self, robot_name: str) -> RobotInfo | None: @@ -143,7 +151,10 @@ def cancel(self) -> bool: def plan_to_joints(self, joints: JointState, robot_name: str | None = None) -> bool: return True - def plan_target_set(self, joint_targets: dict[str, JointState]) -> bool: + def robot_items(self) -> list[tuple[str, str, object]]: + return [] + + def plan_to_joint_targets(self, joint_targets: dict[str, JointState]) -> bool: return True @@ -186,6 +197,7 @@ def test_gui_close_uses_bounded_operation_worker_stop(monkeypatch: pytest.Monkey stop_timeouts: list[float | None] = [] gui = ViserPanelGui( EmptyServer(), + EmptyWorldMonitor(), FakeOperationAdapter(), ViserVisualizationConfig(), ) @@ -203,6 +215,7 @@ def test_gui_only_preview_submits_timeout_override(monkeypatch: pytest.MonkeyPat submissions: list[dict[str, float]] = [] gui = ViserPanelGui( EmptyServer(), + EmptyWorldMonitor(), FakeOperationAdapter(), ViserVisualizationConfig(preview_request_timeout=0.25), ) @@ -229,6 +242,7 @@ def test_gui_cancel_bypasses_operation_worker(monkeypatch: pytest.MonkeyPatch) - adapter = FakeOperationAdapter() gui = ViserPanelGui( EmptyServer(), + EmptyWorldMonitor(), adapter, ViserVisualizationConfig(), ) @@ -254,6 +268,7 @@ def test_gui_cancelled_planning_clears_active_plan_state(monkeypatch: pytest.Mon adapter = FakeOperationAdapter() gui = ViserPanelGui( EmptyServer(), + EmptyWorldMonitor(), adapter, ViserVisualizationConfig(), ) @@ -292,6 +307,7 @@ def test_gui_guard_errors_keep_action_idle( submissions: list[Callable[[], None]] = [] gui = ViserPanelGui( EmptyServer(), + EmptyWorldMonitor(), FakeOperationAdapter(), ViserVisualizationConfig(), ) @@ -312,6 +328,7 @@ def test_gui_guard_errors_keep_action_idle( def test_gui_ignores_stale_timed_out_operation_finish() -> None: gui = ViserPanelGui( EmptyServer(), + EmptyWorldMonitor(), FakeOperationAdapter(), ViserVisualizationConfig(), ) diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index ab3b1803df..29d6c0768a 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -45,9 +45,9 @@ ACTIVE_GROUP_COLOR, INACTIVE_GROUP_COLOR, PRIMARY_ACTION_COLOR, - ViserModuleAccess, ViserPanelGui, ) +from dimos.manipulation.visualization.viser.panel_backend import pose_from_transform_values from dimos.manipulation.visualization.viser.scene import ViserManipulationScene from dimos.manipulation.visualization.viser.state import ( ActionStatus, @@ -404,7 +404,7 @@ def make_planning_group_info( class FakeManipulationModule(SimpleNamespace): - """Public ManipulationModule surface used by the in-process Viser adapter tests.""" + """Public ManipulationModule surface used by the in-process Viser panel tests.""" def list_robots(self) -> list[str]: return list(getattr(self, "_robots", {}).keys()) @@ -557,8 +557,11 @@ def inverse_kinematics( def plan_to_joint_targets(self, _joint_targets: dict[str, JointState]) -> bool: return True + def reset(self) -> bool: + return True + -def make_adapter_with_robot() -> ViserModuleAccess: +def make_module_with_robot() -> tuple[SimpleNamespace, FakeManipulationModule]: current = FakeJointState(["j1", "j2"], position=[0.3, 0.4]) config = make_robot_config( name="arm", @@ -580,7 +583,11 @@ def make_adapter_with_robot() -> ViserModuleAccess: _error_message="", _world_monitor=world_monitor, ) - return ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + return world_monitor, module + + +def joints_from_values(joint_names: Sequence[str], values: Sequence[float]) -> JointState: + return JointState({"name": list(joint_names), "position": [float(value) for value in values]}) @pytest.fixture @@ -590,12 +597,16 @@ def make_panel() -> Iterator[Callable[..., ViserPanelGui]]: def _make( server: FakeGuiServer | FakeServer, - adapter: ViserModuleAccess, + module_context: tuple[SimpleNamespace, FakeManipulationModule], config: ViserVisualizationConfig | None = None, scene: ViserManipulationScene | None = None, ) -> ViserPanelGui: gui = ViserPanelGui( - server, adapter, config or ViserVisualizationConfig(panel_enabled=True), scene + server, + module_context[0], + module_context[1], + config or ViserVisualizationConfig(panel_enabled=True), + scene, ) gui.start() panels.append(gui) @@ -614,8 +625,8 @@ def test_gui_builds_controls_in_manipulation_panel_folder( make_panel: Callable[..., ViserPanelGui], ) -> None: server = FakeGuiServer() - adapter = make_adapter_with_robot() - gui = make_panel(server, adapter, ViserVisualizationConfig()) + module_context = make_module_with_robot() + gui = make_panel(server, module_context, ViserVisualizationConfig()) assert server.folders assert server.folders[0].label == "Manipulation Panel" assert server.folders[0].kwargs == {"expand_by_default": True} @@ -669,8 +680,8 @@ def test_gui_scene_grid_checkbox_toggles_reference_grid( grid_server, lambda *args, **kwargs: FakeUrdf(("joint1",)), preview_fps=10.0 ) server = FakeGuiServer() - adapter = make_adapter_with_robot() - make_panel(server, adapter, ViserVisualizationConfig(), scene) + module_context = make_module_with_robot() + make_panel(server, module_context, ViserVisualizationConfig(), scene) assert grid_server.grids assert server.checkboxes["Scene grid"].value is True server.checkboxes["Scene grid"].update_callback( @@ -691,8 +702,8 @@ def test_gui_close_removes_handles_and_late_callbacks_are_noops( scene = ViserManipulationScene( grid_server, lambda *args, **kwargs: FakeUrdf(("joint1",)), preview_fps=10.0 ) - adapter = make_adapter_with_robot() - gui = make_panel(server, adapter, ViserVisualizationConfig(), scene) + module_context = make_module_with_robot() + gui = make_panel(server, module_context, ViserVisualizationConfig(), scene) plan_button = server.buttons["Plan"] grid = grid_server.grids[0] handles = list(gui._handles.values()) @@ -710,8 +721,8 @@ def test_gui_close_removes_handles_and_late_callbacks_are_noops( def test_gui_ignores_target_evaluation_after_close( make_panel: Callable[..., ViserPanelGui], ) -> None: - adapter = make_adapter_with_robot() - gui = make_panel(FakeGuiServer(), adapter) + module_context = make_module_with_robot() + gui = make_panel(FakeGuiServer(), module_context) gui.state.selected_robot = "arm" sequence_id = gui.state.next_sequence_id() request = TargetEvaluationRequest( @@ -1286,11 +1297,13 @@ def test_gui_initializes_pose_selector_to_current_ee_pose( is_state_stale=lambda robot_id, max_age=1.0: False, get_ee_pose=lambda robot_id, joint_state=None: current_pose, ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) scene = ViserManipulationScene( FakeTransformServer(), lambda *args, **kwargs: FakeViserUrdfWithMeshes(), preview_fps=10.0 ) - gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) + gui = make_panel( + FakeGuiServer(), module_context, ViserVisualizationConfig(panel_enabled=True), scene + ) control = scene._handles[f"{DEFAULT_GROUP_ID}:ee_control"] assert control.position == (0.1, 0.2, 0.3) assert control.wxyz == (0.9, 0.1, 0.2, 0.3) @@ -1312,11 +1325,13 @@ def test_gui_removes_pose_selector_when_group_is_deselected( is_state_stale=lambda robot_id, max_age=1.0: False, get_ee_pose=lambda robot_id, joint_state=None: current_pose, ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) scene = ViserManipulationScene( FakeTransformServer(), lambda *args, **kwargs: FakeViserUrdfWithMeshes(), preview_fps=10.0 ) - gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) + gui = make_panel( + FakeGuiServer(), module_context, ViserVisualizationConfig(panel_enabled=True), scene + ) control = scene._handles[f"{DEFAULT_GROUP_ID}:ee_control"] gui._set_group_selected(DEFAULT_GROUP_ID, False) @@ -1346,7 +1361,7 @@ def test_gui_group_selector_derives_primary_and_auxiliary_groups( {"position": [0.0, 0.0, 0.0], "orientation": [0.0, 0.0, 0.0, 1.0]} ), ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) target_controls = [] scene = SimpleNamespace( has_reference_grid=lambda: False, @@ -1359,7 +1374,7 @@ def test_gui_group_selector_derives_primary_and_auxiliary_groups( ) server = FakeGuiServer() - gui = make_panel(server, adapter, ViserVisualizationConfig(panel_enabled=True), scene) + gui = make_panel(server, module_context, ViserVisualizationConfig(panel_enabled=True), scene) assert "robot" not in gui._handles pose_button = gui._handles["group:arm:manipulator"] aux_button = gui._handles["group:arm:gripper"] @@ -1400,7 +1415,7 @@ def test_gui_target_ghost_visibility_follows_active_selected_groups( {"position": [0.0, 0.0, 0.0], "orientation": [0.0, 0.0, 0.0, 1.0]} ), ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) active_updates = [] scene = SimpleNamespace( has_reference_grid=lambda: False, @@ -1412,7 +1427,9 @@ def test_gui_target_ghost_visibility_follows_active_selected_groups( set_target_visual_state=lambda *args: None, ) - gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) + gui = make_panel( + FakeGuiServer(), module_context, ViserVisualizationConfig(panel_enabled=True), scene + ) assert active_updates[-2:] == [("left-id", True), ("right-id", False)] gui._set_group_selected("right:manipulator", True) @@ -1436,8 +1453,8 @@ def test_gui_preset_dropdown_and_controls_include_init_home_current_and_callback is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: None, ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) - gui = make_panel(FakeGuiServer(), adapter) + module_context = (world_monitor, module) + gui = make_panel(FakeGuiServer(), module_context) assert gui._handles["preset"].options == ["Select preset...", "Init", "Current", "Home"] assert list(gui._joint_sliders) == ["arm/j1", "arm/j2"] gui._apply_preset("Home") @@ -1460,9 +1477,9 @@ def test_gui_rebuilding_joint_sliders_removes_stale_viser_handles( is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: None, ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) server = FakeGuiServer() - gui = make_panel(server, adapter) + gui = make_panel(server, module_context) stale_sliders = list(server.sliders) assert [slider.value for slider in stale_sliders] == [0.0, 0.0] @@ -1479,16 +1496,11 @@ def test_gui_rebuilding_joint_sliders_removes_stale_viser_handles( def test_gui_parses_numpy_transform_control_arrays() -> None: - gui = ViserPanelGui(FakeGuiServer(), make_adapter_with_robot(), ViserVisualizationConfig()) - - pose = gui._pose_from_transform_target( - SimpleNamespace( - position=np.array([1.0, 2.0, 3.0]), - wxyz=np.array([0.5, 0.1, 0.2, 0.3]), - ) + pose = pose_from_transform_values( + np.array([1.0, 2.0, 3.0]), + np.array([0.5, 0.1, 0.2, 0.3]), ) - assert pose is not None assert list(pose.position) == [1.0, 2.0, 3.0] assert list(pose.orientation) == [0.1, 0.2, 0.3, 0.5] @@ -1508,11 +1520,8 @@ def test_panel_execution_is_gated_by_default_and_refresh_updates_robot_controls( is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: None, ) - adapter = ViserModuleAccess( - world_monitor=world_monitor, - manipulation_module=module, - ) - gui = make_panel(FakeGuiServer(), adapter) + module_context = (world_monitor, module) + gui = make_panel(FakeGuiServer(), module_context) gui.refresh() assert gui.state.selected_robot == "arm" assert list(gui._joint_sliders) == ["arm/j1"] @@ -1536,7 +1545,7 @@ def test_gui_moves_joint_target_immediately_and_stores_evaluated_joint_solution( is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: target_pose, ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) target_updates = [] target_pose_updates = [] scene = SimpleNamespace( @@ -1546,7 +1555,9 @@ def test_gui_moves_joint_target_immediately_and_stores_evaluated_joint_solution( set_target_pose=lambda *args: target_pose_updates.append(args), set_target_visual_state=lambda *args: None, ) - gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) + gui = make_panel( + FakeGuiServer(), module_context, ViserVisualizationConfig(panel_enabled=True), scene + ) requests = [] gui._worker.stop() gui._worker = SimpleNamespace( @@ -1571,7 +1582,7 @@ def test_gui_moves_joint_target_immediately_and_stores_evaluated_joint_solution( { "success": True, "collision_free": True, - "target_joints": adapter.joints_from_values(["arm/j1", "arm/j2"], [9.0, 9.0]), + "target_joints": joints_from_values(["arm/j1", "arm/j2"], [9.0, 9.0]), }, ) assert gui.state.target_joints is not None @@ -1583,7 +1594,7 @@ def test_gui_moves_joint_target_immediately_and_stores_evaluated_joint_solution( { "success": True, "collision_free": True, - "target_joints": adapter.joints_from_values(["arm/j1", "arm/j2"], [1.0, 2.0]), + "target_joints": joints_from_values(["arm/j1", "arm/j2"], [1.0, 2.0]), "group_poses": {DEFAULT_GROUP_ID: joint_bar_pose}, }, ) @@ -1608,7 +1619,7 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( is_state_valid=lambda robot_id, joint_state: True, get_ee_pose=lambda robot_id, joint_state=None: None, ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) target_joint_updates = [] target_pose_updates = [] scene = SimpleNamespace( @@ -1618,7 +1629,9 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( set_target_pose=lambda *args: target_pose_updates.append(args), set_target_visual_state=lambda *args: None, ) - gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) + gui = make_panel( + FakeGuiServer(), module_context, ViserVisualizationConfig(panel_enabled=True), scene + ) gui._handles[f"ee_control:{DEFAULT_GROUP_ID}"] = object() dragged_pose = Pose({"position": [0.1, 0.2, 0.3], "orientation": [0.0, 0.0, 0.0, 1.0]}) solved_pose = Pose({"position": [0.4, 0.5, 0.6], "orientation": [0.0, 0.0, 0.0, 1.0]}) @@ -1635,7 +1648,7 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( { "success": True, "collision_free": True, - "target_joints": adapter.joints_from_values(["arm/j1", "arm/j2"], [1.0, 2.0]), + "target_joints": joints_from_values(["arm/j1", "arm/j2"], [1.0, 2.0]), "group_poses": {DEFAULT_GROUP_ID: solved_pose}, }, ) @@ -1662,7 +1675,7 @@ def test_gui_can_disable_collision_check_for_cartesian_target_evaluation( {"position": [0.0, 0.0, 0.0], "orientation": [0.0, 0.0, 0.0, 1.0]} ), ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) scene = SimpleNamespace( has_reference_grid=lambda: False, ensure_target_controls=lambda *args: None, @@ -1672,7 +1685,7 @@ def test_gui_can_disable_collision_check_for_cartesian_target_evaluation( ) gui = make_panel( FakeGuiServer(), - adapter, + module_context, ViserVisualizationConfig(panel_enabled=True), scene, ) @@ -1710,7 +1723,7 @@ def test_gui_collision_evaluation_marks_target_infeasible_and_colors_scene( ), ) module._world_monitor = world_monitor - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) visual_states = [] scene = SimpleNamespace( has_reference_grid=lambda: False, @@ -1719,10 +1732,12 @@ def test_gui_collision_evaluation_marks_target_infeasible_and_colors_scene( set_target_pose=lambda *args: None, set_target_visual_state=lambda *args: visual_states.append(args), ) - gui = make_panel(FakeGuiServer(), adapter, ViserVisualizationConfig(panel_enabled=True), scene) + gui = make_panel( + FakeGuiServer(), module_context, ViserVisualizationConfig(panel_enabled=True), scene + ) request = TargetEvaluationRequest(sequence_id=1, source="joints", group_ids=(DEFAULT_GROUP_ID,)) gui.state.latest_sequence_id = 1 - result = adapter.evaluate_joint_target_set( + result = gui._evaluate_joint_target_set( {DEFAULT_GROUP_ID: FakeJointState(["arm/j1"], position=[1.0])} ) @@ -1758,10 +1773,10 @@ def test_gui_safe_execute_requires_fresh_matching_plan_and_clear_resets_path( position=SimpleNamespace(x=0.0, y=0.0, z=0.0) ), ) - adapter = ViserModuleAccess(world_monitor=world_monitor, manipulation_module=module) + module_context = (world_monitor, module) gui = make_panel( FakeGuiServer(), - adapter, + module_context, ViserVisualizationConfig( panel_enabled=True, allow_plan_execute=True, current_match_tolerance=0.05 ), @@ -1801,8 +1816,8 @@ def test_gui_plan_target_failure_recovers_action_state( make_panel: Callable[..., ViserPanelGui], monkeypatch: pytest.MonkeyPatch, ) -> None: - adapter = make_adapter_with_robot() - gui = make_panel(FakeGuiServer(), adapter) + module_context = make_module_with_robot() + gui = make_panel(FakeGuiServer(), module_context) gui._operation_worker.stop() monkeypatch.setattr( gui, @@ -1829,8 +1844,8 @@ def test_gui_resets_fault_before_replanning( monkeypatch: pytest.MonkeyPatch, ) -> None: calls = [] - adapter = make_adapter_with_robot() - gui = make_panel(FakeGuiServer(), adapter) + module_context = make_module_with_robot() + gui = make_panel(FakeGuiServer(), module_context) gui._operation_worker.stop() monkeypatch.setattr( gui, @@ -1848,8 +1863,8 @@ def plan_target_set(_joint_targets: dict[str, JointState]) -> bool: calls.append("plan") return True - monkeypatch.setattr(adapter, "reset", reset) - monkeypatch.setattr(adapter, "plan_target_set", plan_target_set) + monkeypatch.setattr(module_context[1], "reset", reset) + monkeypatch.setattr(module_context[1], "plan_to_joint_targets", plan_target_set) gui.state.target_status = TargetStatus.FEASIBLE gui.state.manipulation_state = "FAULT" From 8e3d0512cad0ba2a3c44074a5b680f4040d9bae4 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 19:22:26 -0700 Subject: [PATCH 062/110] refactor: simplify viser panel backend --- dimos/manipulation/manipulation_module.py | 35 ++++++---- dimos/manipulation/visualization/viser/gui.py | 24 ++++--- .../visualization/viser/panel_backend.py | 65 ------------------- .../viser/test_operation_worker.py | 5 +- .../viser/test_viser_visualization.py | 10 ++- 5 files changed, 51 insertions(+), 88 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 7756fccccc..2d8bccf31f 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -79,6 +79,7 @@ NoManipulationVisualizationConfig, ) from dimos.manipulation.visualization.factory import create_manipulation_visualization +from dimos.manipulation.visualization.types import PlanningGroupInfo, RobotInfo from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -672,12 +673,16 @@ def check_collision( ) return self._world_monitor.check_collision(target_joints, max_age=max_age) - def list_planning_groups(self) -> list[PlanningGroup]: - """Return all planning groups in stable registry order.""" + def _planning_group_models(self) -> list[PlanningGroup]: + """Return all planning group models in stable registry order.""" if self._world_monitor is None: return [] return list(self._world_monitor.planning_groups.list()) + def list_planning_groups(self) -> list[PlanningGroupInfo]: + """Return all planning groups as visualization-friendly typed dictionaries.""" + return [self._planning_group_info(group) for group in self._planning_group_models()] + def get_current_joint_state(self, robot_name: RobotName) -> JointState | None: """Return the named robot's current local joint state with names.""" if self._world_monitor is None: @@ -1048,7 +1053,7 @@ def list_robots(self) -> list[str]: return list(self._robots.keys()) @rpc - def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] | None: + def get_robot_info(self, robot_name: RobotName | None = None) -> RobotInfo | None: """Get information about a robot. Args: @@ -1064,16 +1069,7 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] robot_name, robot_id, config, _ = robot planning_groups = ( [ - { - "id": group.id, - "name": group.group_name, - "joint_names": list(group.joint_names), - "local_joint_names": list(group.local_joint_names), - "base_link": group.base_link, - "tip_link": group.tip_link, - "source": group.source, - "has_pose_target": group.has_pose_target, - } + self._planning_group_info(group) for group in self._world_monitor.planning_groups.groups_for_robot(robot_name) ] if self._world_monitor is not None @@ -1097,6 +1093,19 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] else None, } + def _planning_group_info(self, group: PlanningGroup) -> PlanningGroupInfo: + return { + "id": group.id, + "name": group.group_name, + "robot_name": group.robot_name, + "joint_names": list(group.joint_names), + "local_joint_names": list(group.local_joint_names), + "base_link": group.base_link, + "tip_link": group.tip_link, + "source": group.source, + "has_pose_target": group.has_pose_target, + } + def robot_items(self) -> list[tuple[RobotName, WorldRobotID, RobotModelConfig]]: """Return configured robots for in-process visualization adapters.""" return [(name, robot_id, config) for name, (robot_id, config, _) in self._robots.items()] diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 630a8dfae2..dc1a296570 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -33,12 +33,8 @@ feasibility_status, get_current_joint_state, get_ee_pose, - group_display_name, - group_selector_color, is_state_stale, joint_values_by_name, - list_planning_groups, - normalize_robot_info, pose_from_transform_values, ) from dimos.manipulation.visualization.viser.runtime import VISER_INSTALL_HINT @@ -99,6 +95,20 @@ INACTIVE_GROUP_COLOR = (52, 52, 52) +def group_display_name(group: PlanningGroupInfo) -> str: + robot_name = str(group["robot_name"]) + group_name = str(group["name"]) + return robot_name if group_name == "manipulator" else f"{robot_name} {group_name}" + + +def group_selector_color( + selected: bool, + active_color: tuple[int, int, int], + inactive_color: tuple[int, int, int], +) -> tuple[int, int, int]: + return active_color if selected else inactive_color + + class ViserPanelGui: """Optional operator panel with parity for the original cc/viser-vis panel.""" @@ -187,12 +197,10 @@ def _list_robots(self) -> list[RobotName]: return list(self.manipulation_module.list_robots()) def _list_planning_groups(self) -> list[PlanningGroupInfo]: - return list_planning_groups(self.manipulation_module) + return self.manipulation_module.list_planning_groups() def _get_robot_info(self, robot_name: RobotName) -> RobotInfo | None: - return normalize_robot_info( - cast("RobotInfo | None", self.manipulation_module.get_robot_info(robot_name)) - ) + return self.manipulation_module.get_robot_info(robot_name) def _get_init_joints(self, robot_name: RobotName) -> JointState | None: return copy_joint_state(self.manipulation_module.get_init_joints(robot_name)) diff --git a/dimos/manipulation/visualization/viser/panel_backend.py b/dimos/manipulation/visualization/viser/panel_backend.py index fa28371070..4934a9eb6c 100644 --- a/dimos/manipulation/visualization/viser/panel_backend.py +++ b/dimos/manipulation/visualization/viser/panel_backend.py @@ -20,7 +20,6 @@ from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName from dimos.manipulation.visualization.types import ( PlanningGroupInfo, - RobotInfo, TargetSetEvaluation, ) from dimos.manipulation.visualization.viser.state import FeasibilityStatus @@ -37,56 +36,6 @@ def copy_joint_state(joint_state: JointState | None) -> JointState | None: return None if joint_state is None else JointState(joint_state) -def normalize_robot_info(info: RobotInfo | None) -> RobotInfo | None: - if info is None: - return None - coordinator_task_name = info.get("coordinator_task_name") - home_joints = info.get("home_joints") - init_joints = info.get("init_joints") - robot_name = str(info.get("name", "")) - return { - "name": robot_name, - "world_robot_id": str(info.get("world_robot_id", "")), - "joint_names": [str(name) for name in info.get("joint_names", [])], - "end_effector_link": str(info.get("end_effector_link", "")), - "base_link": str(info.get("base_link", "")), - "max_velocity": float(info.get("max_velocity", 0.0)), - "max_acceleration": float(info.get("max_acceleration", 0.0)), - "has_joint_name_mapping": bool(info.get("has_joint_name_mapping", False)), - "coordinator_task_name": None - if coordinator_task_name is None - else str(coordinator_task_name), - "home_joints": None if home_joints is None else [float(value) for value in home_joints], - "pre_grasp_offset": float(info.get("pre_grasp_offset", 0.0)), - "init_joints": None if init_joints is None else [float(value) for value in init_joints], - "planning_groups": [ - { - "id": str(group["id"]), - "name": str(group["name"]), - "robot_name": robot_name, - "joint_names": [str(name) for name in group["joint_names"]], - "local_joint_names": [str(name) for name in group["local_joint_names"]], - "base_link": str(group["base_link"]), - "tip_link": None if group["tip_link"] is None else str(group["tip_link"]), - "has_pose_target": bool(group["has_pose_target"]), - "source": str(group["source"]), - } - for group in info.get("planning_groups", []) - ], - } - - -def list_planning_groups(manipulation_module: ManipulationModule) -> list[PlanningGroupInfo]: - groups: list[PlanningGroupInfo] = [] - for robot_name in manipulation_module.list_robots(): - info = normalize_robot_info( - cast("RobotInfo | None", manipulation_module.get_robot_info(robot_name)) - ) - if info is not None: - groups.extend(info.get("planning_groups", [])) - return groups - - def get_current_joint_state( world_monitor: WorldMonitor, manipulation_module: ManipulationModule, @@ -279,20 +228,6 @@ def pose_from_transform_values(position: Sequence[float], wxyz: Sequence[float]) return Pose({"position": [px, py, pz], "orientation": [qx, qy, qz, qw]}) -def group_display_name(group: PlanningGroupInfo) -> str: - robot_name = str(group["robot_name"]) - group_name = str(group["name"]) - return robot_name if group_name == "manipulator" else f"{robot_name} {group_name}" - - -def group_selector_color( - selected: bool, - active_color: tuple[int, int, int], - inactive_color: tuple[int, int, int], -) -> tuple[int, int, int]: - return active_color if selected else inactive_color - - def feasibility_status( status: str, success: bool, diff --git a/dimos/manipulation/visualization/viser/test_operation_worker.py b/dimos/manipulation/visualization/viser/test_operation_worker.py index 3e6d34459a..8e4dd39c53 100644 --- a/dimos/manipulation/visualization/viser/test_operation_worker.py +++ b/dimos/manipulation/visualization/viser/test_operation_worker.py @@ -22,7 +22,7 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") -from dimos.manipulation.visualization.types import RobotInfo +from dimos.manipulation.visualization.types import PlanningGroupInfo, RobotInfo from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig from dimos.manipulation.visualization.viser.gui import ViserPanelGui from dimos.manipulation.visualization.viser.state import ( @@ -129,6 +129,9 @@ def get_state(self) -> str: def get_robot_info(self, robot_name: str) -> RobotInfo | None: return None + def list_planning_groups(self) -> list[PlanningGroupInfo]: + return [] + def get_current_joint_state(self, robot_name: str) -> None: return None diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index 29d6c0768a..d62255ffea 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -31,7 +31,7 @@ ForwardKinematicsResult, IKResult, ) -from dimos.manipulation.visualization.types import RobotInfo +from dimos.manipulation.visualization.types import PlanningGroupInfo, RobotInfo from dimos.manipulation.visualization.viser import scene as scene_module from dimos.manipulation.visualization.viser.animation import ( GroupPreviewAnimation, @@ -458,6 +458,14 @@ def get_robot_info(self, robot_name: str) -> RobotInfo | None: "init_joints": list(init.position) if init is not None else None, } + def list_planning_groups(self) -> list[PlanningGroupInfo]: + groups: list[PlanningGroupInfo] = [] + for robot_name in self.list_robots(): + info = self.get_robot_info(robot_name) + if info is not None: + groups.extend(info.get("planning_groups", [])) + return groups + def get_init_joints(self, robot_name: str) -> JointState | None: return getattr(self, "_init_joints", {}).get(robot_name) From 6e55e8d3db35c8721bf4f46c8fbd940e117a3f22 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 19:55:53 -0700 Subject: [PATCH 063/110] refactor: address movegroup review cleanup --- dimos/manipulation/manipulation_module.py | 119 ++++-------------- .../planning/kinematics/pink_ik.py | 114 +++++++++-------- .../planning/kinematics/test_pink_ik.py | 36 +++--- .../planning/monitor/world_monitor.py | 8 +- dimos/manipulation/planning/spec/protocols.py | 2 +- .../planning/world/drake_world.py | 4 +- .../world/test_drake_world_planning_groups.py | 2 +- dimos/manipulation/test_manipulation_unit.py | 12 +- dimos/manipulation/visualization/types.py | 15 +-- dimos/manipulation/visualization/viser/gui.py | 87 +++++++------ .../visualization/viser/panel_backend.py | 14 +-- .../viser/test_operation_worker.py | 5 +- .../viser/test_viser_visualization.py | 38 +++--- dimos/robot/catalog/openarm.py | 116 ----------------- 14 files changed, 183 insertions(+), 389 deletions(-) delete mode 100644 dimos/robot/catalog/openarm.py diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 2d8bccf31f..2d4adf462a 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -79,7 +79,7 @@ NoManipulationVisualizationConfig, ) from dimos.manipulation.visualization.factory import create_manipulation_visualization -from dimos.manipulation.visualization.types import PlanningGroupInfo, RobotInfo +from dimos.manipulation.visualization.types import RobotInfo from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -368,7 +368,7 @@ def _tf_publish_loop(self) -> None: if pose_group_id is not None: pose_group = self._world_monitor.planning_groups.get(pose_group_id) target_frame = pose_group.tip_link - ee_pose = self._world_monitor.get_group_pose(pose_group_id) + ee_pose = self._world_monitor.get_group_ee_pose(pose_group_id) else: ee_pose = ( self._world_monitor.get_link_pose(robot_id, target_frame) @@ -466,7 +466,7 @@ def get_ee_pose(self, robot_name: RobotName | None = None) -> Pose | None: _, robot_id, config, _ = robot pose_group_id = self._primary_pose_group_id_for_robot(config.name) if pose_group_id is not None: - return self._world_monitor.get_group_pose(pose_group_id, joint_state=None) + return self._world_monitor.get_group_ee_pose(pose_group_id, joint_state=None) if config.end_effector_link is None: return None return self._world_monitor.get_link_pose(robot_id, config.end_effector_link) @@ -486,20 +486,18 @@ def is_collision_free(self, joints: list[float], robot_name: RobotName | None = return self._world_monitor.is_state_valid(robot_id, joint_state) return False - def _begin_planning( - self, group_ids: Sequence[PlanningGroupID] - ) -> tuple[PlanningGroupID, ...] | None: + def _begin_planning(self) -> bool: """Check state and begin planning for the selected planning groups.""" if self._world_monitor is None: logger.error("Planning not initialized") - return None + return False with self._lock: if self._state not in (ManipulationState.IDLE, ManipulationState.COMPLETED): logger.warning(f"Cannot plan: state is {self._state.name}") - return None + return False self._planning_epoch += 1 self._state = ManipulationState.PLANNING - return tuple(group_ids) + return True def _fail(self, msg: str) -> bool: """Set FAULT state with error message.""" @@ -525,29 +523,6 @@ def _primary_pose_group_id_for_robot(self, robot_name: RobotName) -> PlanningGro assert self._world_monitor is not None return self._world_monitor.planning_groups.primary_pose_group_id_for_robot(robot_name) - def _current_positions_by_name( - self, robot_name: RobotName, current: JointState - ) -> dict[str, float] | None: - """Index a robot current state by local model joint name.""" - if len(current.name) != len(current.position): - logger.error( - "Current state for '%s' has %d names but %d positions", - robot_name, - len(current.name), - len(current.position), - ) - return None - if not current.name: - logger.error("Current state for '%s' has no joint names", robot_name) - return None - try: - assert_local_joint_names(current.name) - except ValueError as exc: - logger.error("Invalid current state for '%s': %s", robot_name, exc) - return None - - return dict(zip(current.name, current.position, strict=True)) - def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> JointState | None: """Collect current state for exactly the selected global joints.""" assert self._world_monitor is not None @@ -562,37 +537,8 @@ def _selected_joint_state(self, group_ids: tuple[PlanningGroupID, ...]) -> Joint if current is None: logger.error("No fresh planning-world joint state") return None - - # Compatibility for older tests/mocks that provide only robot-local - # get_current_joint_state() behavior instead of the new primitive. - robot_ids_by_name: dict[RobotName, WorldRobotID] = {} - for robot_name in selection.robot_names: - try: - robot_ids_by_name[robot_name] = self._robots[robot_name][0] - except KeyError: - logger.error("Robot '%s' is not registered", robot_name) - return None - names: list[str] = [] - positions: list[float] = [] - for group in selection.groups: - current_local = self._world_monitor.get_current_joint_state( - robot_ids_by_name[group.robot_name] - ) - if current_local is None: - logger.error("No joint state for robot '%s'", group.robot_name) - return None - current_by_name = self._current_positions_by_name(group.robot_name, current_local) - if current_by_name is None: - return None - for global_name, local_name in zip( - group.joint_names, group.local_joint_names, strict=True - ): - if local_name not in current_by_name: - logger.error("Current state missing selected joint '%s'", global_name) - return None - names.append(global_name) - positions.append(float(current_by_name[local_name])) - return JointState(name=names, position=positions) + logger.error("Invalid planning-world joint state") + return None def _joint_target_to_global_names( self, group_id: PlanningGroupID, target: JointState @@ -679,9 +625,9 @@ def _planning_group_models(self) -> list[PlanningGroup]: return [] return list(self._world_monitor.planning_groups.list()) - def list_planning_groups(self) -> list[PlanningGroupInfo]: - """Return all planning groups as visualization-friendly typed dictionaries.""" - return [self._planning_group_info(group) for group in self._planning_group_models()] + def list_planning_groups(self) -> list[PlanningGroup]: + """Return all planning groups.""" + return self._planning_group_models() def get_current_joint_state(self, robot_name: RobotName) -> JointState | None: """Return the named robot's current local joint state with names.""" @@ -769,7 +715,7 @@ def forward_kinematics( ) current = self._world_monitor.get_current_joint_state(robot_id) current_by_local_name = ( - self._current_positions_by_name(group.robot_name, current) + dict(zip(current.name, current.position, strict=False)) if current is not None else {} ) @@ -786,7 +732,7 @@ def forward_kinematics( joint_state = JointState(name=list(config.joint_names), position=positions) try: - pose = self._world_monitor.get_group_pose(group_id, joint_state) + pose = self._world_monitor.get_group_ee_pose(group_id, joint_state) except Exception as exc: return ForwardKinematicsResult( status="UNAVAILABLE", @@ -916,10 +862,8 @@ def plan_to_pose_targets( } auxiliary_ids = tuple(planning_group_id_from_selector(group) for group in auxiliary_groups) group_ids = tuple(dict.fromkeys((*stamped_targets.keys(), *auxiliary_ids))) - planned_group_ids = self._begin_planning(group_ids) - if planned_group_ids is None: + if not self._begin_planning(): return False - group_ids = planned_group_ids try: start = self._selected_joint_state(group_ids) @@ -968,10 +912,8 @@ def plan_to_joint_targets( group_ids = tuple( dict.fromkeys(planning_group_id_from_selector(group) for group in joint_targets) ) - planned_group_ids = self._begin_planning(group_ids) - if planned_group_ids is None: + if not self._begin_planning(): return False - group_ids = planned_group_ids try: start = self._selected_joint_state(group_ids) except Exception as exc: @@ -1068,10 +1010,7 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> RobotInfo | Non robot_name, robot_id, config, _ = robot planning_groups = ( - [ - self._planning_group_info(group) - for group in self._world_monitor.planning_groups.groups_for_robot(robot_name) - ] + [group for group in self._world_monitor.planning_groups.groups_for_robot(robot_name)] if self._world_monitor is not None else [] ) @@ -1093,19 +1032,6 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> RobotInfo | Non else None, } - def _planning_group_info(self, group: PlanningGroup) -> PlanningGroupInfo: - return { - "id": group.id, - "name": group.group_name, - "robot_name": group.robot_name, - "joint_names": list(group.joint_names), - "local_joint_names": list(group.local_joint_names), - "base_link": group.base_link, - "tip_link": group.tip_link, - "source": group.source, - "has_pose_target": group.has_pose_target, - } - def robot_items(self) -> list[tuple[RobotName, WorldRobotID, RobotModelConfig]]: """Return configured robots for in-process visualization adapters.""" return [(name, robot_id, config) for name, (robot_id, config, _) in self._robots.items()] @@ -1290,13 +1216,12 @@ def execute_plan(self, plan: GeneratedPlan | None = None) -> bool: logger.error(f"No coordinator_task_name for '{resolved_name}'") return False - current_by_name: dict[str, float] = {} current = self._world_monitor.get_current_joint_state(robot_id) - if current is not None: - indexed_current = self._current_positions_by_name(resolved_name, current) - if indexed_current is None: - return False - current_by_name = indexed_current + current_by_name = ( + dict(zip(current.name, current.position, strict=False)) + if current is not None + else {} + ) global_joint_names = make_global_joint_names(resolved_name, config.joint_names) local_path: list[JointState] = [] diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index 790a69f10e..06e53fbd1c 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -46,6 +46,18 @@ if TYPE_CHECKING: from numpy.typing import NDArray +try: + import pink + import pinocchio + import qpsolvers +except ImportError as exc: + pink = None # type: ignore[assignment] + pinocchio = None # type: ignore[assignment] + qpsolvers = None # type: ignore[assignment] + _PINK_IMPORT_ERROR: ImportError | None = exc +else: + _PINK_IMPORT_ERROR = None + logger = setup_logger() @@ -56,12 +68,6 @@ class PinkIKDependencyError(ImportError): PinkIKConfig = PinkKinematicsConfig -@dataclass(frozen=True) -class _PinkModules: - pink: Any - pinocchio: Any - - _MANIPULATION_EXTRA_HINT = "Install manipulation dependencies with: uv sync --extra manipulation." @@ -108,7 +114,7 @@ def __init__( config_values = (config or PinkKinematicsConfig()).model_dump() config_values.update(overrides) self.config = PinkKinematicsConfig(**config_values) - self._modules = _load_optional_dependencies(self.config.solver) + _check_optional_dependencies(self.config.solver) self._robot_contexts: dict[str, _PinkRobotContext] = {} def solve( @@ -303,13 +309,14 @@ def _solve_single( position_tolerance: float, orientation_tolerance: float, ) -> IKResult: - pink = self._modules.pink - pinocchio = self._modules.pinocchio - - configuration = pink.Configuration(robot_context.model, robot_context.data, seed_q.copy()) - target_se3 = _matrix_to_se3(pinocchio, target_model) + pink_module = _pink() + pinocchio_module = _pinocchio() + configuration = pink_module.Configuration( + robot_context.model, robot_context.data, seed_q.copy() + ) + target_se3 = _matrix_to_se3(pinocchio_module, target_model) - frame_task = pink.tasks.FrameTask( + frame_task = pink_module.tasks.FrameTask( robot_context.frame_name, position_cost=self.config.position_cost, orientation_cost=self.config.orientation_cost, @@ -320,7 +327,7 @@ def _solve_single( tasks: list[Any] = [frame_task] if self.config.posture_cost > 0.0: - posture_task = pink.tasks.PostureTask(cost=self.config.posture_cost) + posture_task = pink_module.tasks.PostureTask(cost=self.config.posture_cost) posture_task.set_target_from_configuration(configuration) tasks.append(posture_task) @@ -344,7 +351,7 @@ def _solve_single( iteration + 1, ) - velocity = pink.solve_ik( + velocity = pink_module.solve_ik( configuration, tasks, self.config.dt, @@ -385,27 +392,27 @@ def _solve_multi_frame( orientation_tolerance: float, ) -> IKResult: """Solve multiple frame tasks for one robot model.""" - pink = self._modules.pink - pinocchio = self._modules.pinocchio + pink_module = _pink() + pinocchio_module = _pinocchio() primary_context = robot_contexts[0] - configuration = pink.Configuration( + configuration = pink_module.Configuration( primary_context.model, primary_context.data, seed_q.copy() ) tasks: list[Any] = [] for context, target_model in zip(robot_contexts, target_models, strict=True): - frame_task = pink.tasks.FrameTask( + frame_task = pink_module.tasks.FrameTask( context.frame_name, position_cost=self.config.position_cost, orientation_cost=self.config.orientation_cost, lm_damping=self.config.lm_damping, gain=self.config.gain, ) - frame_task.set_target(_matrix_to_se3(pinocchio, target_model)) + frame_task.set_target(_matrix_to_se3(pinocchio_module, target_model)) tasks.append(frame_task) if self.config.posture_cost > 0.0: - posture_task = pink.tasks.PostureTask(cost=self.config.posture_cost) + posture_task = pink_module.tasks.PostureTask(cost=self.config.posture_cost) posture_task.set_target_from_configuration(configuration) tasks.append(posture_task) @@ -433,7 +440,7 @@ def _solve_multi_frame( iteration + 1, ) - velocity = pink.solve_ik( + velocity = pink_module.solve_ik( configuration, tasks, self.config.dt, @@ -484,13 +491,13 @@ def _get_robot_context( def _build_robot_context( self, config: RobotModelConfig, frame_name: str | None = None ) -> _PinkRobotContext: - pinocchio = self._modules.pinocchio + pinocchio_module = _pinocchio() model_path = Path(config.model_path).resolve() if not model_path.exists(): raise FileNotFoundError(f"Robot model not found: {model_path}") if model_path.suffix == ".xml": - model = pinocchio.buildModelFromMJCF(str(model_path)) + model = pinocchio_module.buildModelFromMJCF(str(model_path)) else: prepared_path = prepare_urdf_for_drake( urdf_path=model_path, @@ -501,8 +508,8 @@ def _build_robot_context( if config.strip_model_world_joint else None, ) - model = pinocchio.buildModelFromUrdf(str(prepared_path)) - model = _lock_uncontrolled_model_joints(pinocchio, model, config) + model = pinocchio_module.buildModelFromUrdf(str(prepared_path)) + model = _lock_uncontrolled_model_joints(pinocchio_module, model, config) data = model.createData() target_frame = frame_name or config.end_effector_link @@ -526,8 +533,7 @@ def _initial_q( upper_limits: NDArray[np.float64], attempt: int, ) -> NDArray[np.float64]: - pinocchio = self._modules.pinocchio - neutral = pinocchio.neutral(context.model) + neutral = _pinocchio().neutral(context.model) q = np.array(neutral, dtype=np.float64) if attempt == 0: @@ -547,9 +553,9 @@ def _q_to_dimos_positions( def _current_frame_matrix( self, context: _PinkRobotContext, q: NDArray[np.float64] ) -> NDArray[np.float64]: - pinocchio = self._modules.pinocchio - pinocchio.forwardKinematics(context.model, context.data, q) - pinocchio.updateFramePlacements(context.model, context.data) + pinocchio_module = _pinocchio() + pinocchio_module.forwardKinematics(context.model, context.data, q) + pinocchio_module.updateFramePlacements(context.model, context.data) placement = context.data.oMf[context.frame_id] matrix: NDArray[np.float64] = np.eye(4) matrix[:3, :3] = np.asarray(placement.rotation, dtype=np.float64) @@ -567,29 +573,13 @@ def _target_in_model_frame( return target_model -def _load_optional_dependencies(solver: str) -> _PinkModules: - try: - import pink - except ImportError as exc: - raise PinkIKDependencyError( - "Pink IK backend requires Pink. " - f"{_MANIPULATION_EXTRA_HINT} PyPI package: pin-pink; import name: pink." - ) from exc - try: - import pinocchio - except ImportError as exc: - raise PinkIKDependencyError( - "Pink IK backend requires Pinocchio (import name 'pinocchio'). " - f"{_MANIPULATION_EXTRA_HINT}" - ) from exc - try: - import qpsolvers - except ImportError as exc: +def _check_optional_dependencies(solver: str) -> None: + if _PINK_IMPORT_ERROR is not None or pink is None or pinocchio is None or qpsolvers is None: raise PinkIKDependencyError( - "Pink IK backend requires qpsolvers plus a QP backend such as proxqp. " - f"{_MANIPULATION_EXTRA_HINT}" - ) from exc - + "Pink IK backend requires Pink, Pinocchio, and qpsolvers plus a QP backend " + f"such as proxqp. {_MANIPULATION_EXTRA_HINT} PyPI package: pin-pink; " + "import names: pink, pinocchio, qpsolvers." + ) from _PINK_IMPORT_ERROR available_solvers = set(getattr(qpsolvers, "available_solvers", [])) if solver not in available_solvers: raise PinkIKDependencyError( @@ -599,7 +589,23 @@ def _load_optional_dependencies(solver: str) -> _PinkModules: "which includes qpsolvers[proxqp]." ) - return _PinkModules(pink=pink, pinocchio=pinocchio) + +def _pink() -> Any: + if _PINK_IMPORT_ERROR is not None or pink is None: + raise PinkIKDependencyError( + "Pink IK backend requires Pink. " + f"{_MANIPULATION_EXTRA_HINT} PyPI package: pin-pink; import name: pink." + ) from _PINK_IMPORT_ERROR + return pink + + +def _pinocchio() -> Any: + if _PINK_IMPORT_ERROR is not None or pinocchio is None: + raise PinkIKDependencyError( + "Pink IK backend requires Pinocchio (import name 'pinocchio'). " + f"{_MANIPULATION_EXTRA_HINT}" + ) from _PINK_IMPORT_ERROR + return pinocchio def _build_joint_mapping(model: Any, config: RobotModelConfig) -> _JointMapping: diff --git a/dimos/manipulation/planning/kinematics/test_pink_ik.py b/dimos/manipulation/planning/kinematics/test_pink_ik.py index f380589542..caa7a10000 100644 --- a/dimos/manipulation/planning/kinematics/test_pink_ik.py +++ b/dimos/manipulation/planning/kinematics/test_pink_ik.py @@ -26,6 +26,7 @@ from dimos.manipulation.planning.factory import create_kinematics from dimos.manipulation.planning.groups.models import PlanningGroup +from dimos.manipulation.planning.kinematics import pink_ik as pink_ik_module from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.kinematics.pink_ik import ( PinkIK, @@ -33,7 +34,6 @@ PinkIKDependencyError, _build_joint_mapping, _lock_uncontrolled_model_joints, - _PinkModules, _PinkRobotContext, _seed_for_robot_config, _seed_positions_for_mapping, @@ -129,7 +129,7 @@ def set_target_from_configuration(self, configuration: _FakeConfiguration) -> No self.target = configuration.q.copy() -def _fake_modules(converge: bool = True) -> _PinkModules: +def _fake_modules(converge: bool = True) -> tuple[ModuleType, ModuleType]: pinocchio = ModuleType("pinocchio") pinocchio.SE3 = _FakeSE3 # type: ignore[attr-defined] pinocchio.neutral = lambda model: np.zeros(model.nq) # type: ignore[attr-defined] @@ -161,7 +161,15 @@ def solve_ik( pink.solve_ik = solve_ik # type: ignore[attr-defined] - return _PinkModules(pink=pink, pinocchio=pinocchio) + return pink, pinocchio + + +def _install_fake_modules(converge: bool = True) -> None: + pink, pinocchio = _fake_modules(converge=converge) + pink_ik_module.pink = pink + pink_ik_module.pinocchio = pinocchio + pink_ik_module.qpsolvers = SimpleNamespace(available_solvers=["proxqp"]) + pink_ik_module._PINK_IMPORT_ERROR = None def _robot_config() -> RobotModelConfig: @@ -187,7 +195,7 @@ def _pose_stamped(x: float, y: float, z: float, yaw: float = 0.0) -> PoseStamped class _TestPinkIK(PinkIK): def __init__(self, converge: bool = True) -> None: self.config = PinkIKConfig(max_iterations=3) - self._modules = _fake_modules(converge=converge) + _install_fake_modules(converge=converge) self._robot_contexts = {} @@ -300,15 +308,13 @@ def get_joint_state(self, ctx: object, robot_id: str) -> JointState: def test_create_kinematics_pink_missing_dependency_is_actionable( monkeypatch: pytest.MonkeyPatch, ) -> None: - from dimos.manipulation.planning.kinematics import pink_ik - def missing_dependencies(_solver: str) -> object: raise PinkIKDependencyError( "Pink IK backend requires Pink. Install manipulation dependencies with: " "uv sync --extra manipulation. PyPI package: pin-pink; import name: pink." ) - monkeypatch.setattr(pink_ik, "_load_optional_dependencies", missing_dependencies) + monkeypatch.setattr(pink_ik_module, "_check_optional_dependencies", missing_dependencies) with pytest.raises(PinkIKDependencyError) as exc_info: create_kinematics("pink") @@ -319,24 +325,20 @@ def missing_dependencies(_solver: str) -> object: def test_create_kinematics_pink_unavailable_solver_mentions_manipulation_extra( monkeypatch: pytest.MonkeyPatch, ) -> None: - from dimos.manipulation.planning.kinematics import pink_ik - def unavailable_solver(_solver: str) -> object: raise PinkIKDependencyError( "Pink IK solver 'proxqp' is not available from qpsolvers. " "Install manipulation dependencies with uv sync --extra manipulation." ) - monkeypatch.setattr(pink_ik, "_load_optional_dependencies", unavailable_solver) + monkeypatch.setattr(pink_ik_module, "_check_optional_dependencies", unavailable_solver) with pytest.raises(PinkIKDependencyError, match="--extra manipulation"): create_kinematics("pink") def test_create_kinematics_pink_returns_backend(monkeypatch: pytest.MonkeyPatch) -> None: - from dimos.manipulation.planning.kinematics import pink_ik - - monkeypatch.setattr(pink_ik, "_load_optional_dependencies", lambda solver: _fake_modules()) + _install_fake_modules() assert isinstance(create_kinematics("pink"), PinkIK) @@ -344,9 +346,7 @@ def test_create_kinematics_pink_returns_backend(monkeypatch: pytest.MonkeyPatch) def test_create_kinematics_pink_config_passes_tuning( monkeypatch: pytest.MonkeyPatch, ) -> None: - from dimos.manipulation.planning.kinematics import pink_ik - - monkeypatch.setattr(pink_ik, "_load_optional_dependencies", lambda solver: _fake_modules()) + _install_fake_modules() ik = create_kinematics(config=PinkKinematicsConfig(max_iterations=7, dt=0.02, posture_cost=0.0)) @@ -357,9 +357,7 @@ def test_create_kinematics_pink_config_passes_tuning( def test_pink_ik_config_overrides_are_applied(monkeypatch: pytest.MonkeyPatch) -> None: - from dimos.manipulation.planning.kinematics import pink_ik - - monkeypatch.setattr(pink_ik, "_load_optional_dependencies", lambda solver: _fake_modules()) + _install_fake_modules() ik = PinkIK(PinkIKConfig(solver="proxqp", dt=0.1), max_iterations=7, posture_cost=0.0) diff --git a/dimos/manipulation/planning/monitor/world_monitor.py b/dimos/manipulation/planning/monitor/world_monitor.py index 42cd934d07..111d33ea74 100644 --- a/dimos/manipulation/planning/monitor/world_monitor.py +++ b/dimos/manipulation/planning/monitor/world_monitor.py @@ -496,12 +496,12 @@ def get_ee_pose( self, robot_id: WorldRobotID, joint_state: JointState | None = None ) -> PoseStamped: """Get end-effector pose for the robot's unique pose-targetable group.""" - return self.get_group_pose( + return self.get_group_ee_pose( self._unique_pose_group_id_for_robot(robot_id), joint_state=joint_state, ) - def get_group_pose( + def get_group_ee_pose( self, group_id: PlanningGroupID, joint_state: JointState | None = None ) -> PoseStamped: """Get planning group target-frame pose using current state by default.""" @@ -512,7 +512,7 @@ def get_group_pose( if joint_state is not None: self._world.set_joint_state(ctx, robot_id, joint_state) - return self._world.get_group_pose(ctx, group_id) + return self._world.get_group_ee_pose(ctx, group_id) def get_link_pose( self, robot_id: WorldRobotID, link_name: str, joint_state: JointState | None = None @@ -571,7 +571,7 @@ def _unique_pose_group_id_for_robot(self, robot_id: WorldRobotID) -> PlanningGro if len(pose_group_ids) != 1: raise ValueError( f"Robot '{robot_name}' has {len(pose_group_ids)} pose-targetable planning groups; " - "call get_group_pose/get_group_jacobian with an explicit planning group ID" + "call get_group_ee_pose/get_group_jacobian with an explicit planning group ID" ) return pose_group_ids[0] diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index 4b14c87478..3c0dca3cf5 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -159,7 +159,7 @@ def check_edge_collision_free( ... # Forward Kinematics (require context) - def get_group_pose(self, ctx: Any, group_id: PlanningGroupID) -> PoseStamped: + def get_group_ee_pose(self, ctx: Any, group_id: PlanningGroupID) -> PoseStamped: """Get pose for a planning group's target frame.""" ... diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index c9b60a378e..05ca3b268b 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -895,7 +895,7 @@ def get_joint_state(self, ctx: Context, robot_id: WorldRobotID) -> JointState: if robot_data.ee_frame is None: raise ValueError( f"Robot '{robot_id}' has no robot-scoped end-effector link; " - "use get_group_pose() with an explicit planning group ID" + "use get_group_ee_pose() with an explicit planning group ID" ) plant_ctx = self._diagram.GetSubsystemContext(self._plant, ctx) full_positions = self._plant.GetPositions(plant_ctx) @@ -985,7 +985,7 @@ def check_edge_collision_free( # Forward Kinematics (context-based) - def get_group_pose(self, ctx: Context, group_id: PlanningGroupID) -> PoseStamped: + def get_group_ee_pose(self, ctx: Context, group_id: PlanningGroupID) -> PoseStamped: """Get pose for a planning group's target frame.""" if not self._finalized: raise RuntimeError("World must be finalized first") diff --git a/dimos/manipulation/planning/world/test_drake_world_planning_groups.py b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py index edaf5cacfe..49d3a3211e 100644 --- a/dimos/manipulation/planning/world/test_drake_world_planning_groups.py +++ b/dimos/manipulation/planning/world/test_drake_world_planning_groups.py @@ -207,4 +207,4 @@ def test_group_pose_rejects_group_without_target_frame() -> None: world._finalized = True with pytest.raises(ValueError, match="left/waist.*no pose target frame"): - world.get_group_pose(None, "left/waist") + world.get_group_ee_pose(None, "left/waist") diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index ca453301f8..833feb029b 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -165,20 +165,18 @@ def test_begin_planning_state_checks(self, robot_config): module = _make_module() module._world_monitor = MagicMock() module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} - group_ids = ("test_arm/manipulator",) - # From IDLE - OK module._state = ManipulationState.IDLE - assert module._begin_planning(group_ids) == group_ids + assert module._begin_planning() is True assert module._state == ManipulationState.PLANNING # From COMPLETED - OK module._state = ManipulationState.COMPLETED - assert module._begin_planning(group_ids) == group_ids + assert module._begin_planning() is True # From EXECUTING - Fail module._state = ManipulationState.EXECUTING - assert module._begin_planning(group_ids) is None + assert module._begin_planning() is False class TestRobotSelection: @@ -383,7 +381,7 @@ def test_forward_kinematics_accepts_extra_global_joints_and_requires_group_joint name=["joint1", "joint2", "joint3"], position=[0.0, 2.0, 3.0] ) pose = PoseStamped(position=Vector3(x=1.0), orientation=Quaternion()) - module._world_monitor.get_group_pose.return_value = pose + module._world_monitor.get_group_ee_pose.return_value = pose result = module.forward_kinematics( "test_arm/wrist", @@ -392,7 +390,7 @@ def test_forward_kinematics_accepts_extra_global_joints_and_requires_group_joint assert result.status == "VALID" assert result.pose is pose - resolved_state = module._world_monitor.get_group_pose.call_args.args[1] + resolved_state = module._world_monitor.get_group_ee_pose.call_args.args[1] assert resolved_state.name == ["joint1", "joint2", "joint3"] assert resolved_state.position == [1.0, 9.0, 3.0] diff --git a/dimos/manipulation/visualization/types.py b/dimos/manipulation/visualization/types.py index a4c22edcd7..eb69e81768 100644 --- a/dimos/manipulation/visualization/types.py +++ b/dimos/manipulation/visualization/types.py @@ -16,6 +16,7 @@ from typing import TypedDict +from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName, WorldRobotID from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -59,16 +60,4 @@ class RobotInfo(TypedDict, total=False): home_joints: list[float] | None pre_grasp_offset: float init_joints: list[float] | None - planning_groups: list[PlanningGroupInfo] - - -class PlanningGroupInfo(TypedDict): - id: PlanningGroupID - name: str - robot_name: RobotName - joint_names: list[str] - local_joint_names: list[str] - base_link: str - tip_link: str | None - has_pose_target: bool - source: str + planning_groups: list[PlanningGroup] diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index dc1a296570..0f5c29690b 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -20,7 +20,6 @@ from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName from dimos.manipulation.visualization.types import ( - PlanningGroupInfo, RobotInfo, TargetEvaluation, TargetSetEvaluation, @@ -95,9 +94,9 @@ INACTIVE_GROUP_COLOR = (52, 52, 52) -def group_display_name(group: PlanningGroupInfo) -> str: - robot_name = str(group["robot_name"]) - group_name = str(group["name"]) +def group_display_name(group: PlanningGroup) -> str: + robot_name = group.robot_name + group_name = group.group_name return robot_name if group_name == "manipulator" else f"{robot_name} {group_name}" @@ -175,10 +174,8 @@ def refresh(self) -> None: BackendConnectionStatus.READY if robots else BackendConnectionStatus.WAITING_FOR_ROBOT ) if not self.state.selected_group_ids and groups and not self._default_group_initialized: - first_pose_group = next( - (group for group in groups if bool(group["has_pose_target"])), groups[0] - ) - self.state.selected_group_ids = (str(first_pose_group["id"]),) + first_pose_group = next((group for group in groups if group.has_pose_target), groups[0]) + self.state.selected_group_ids = (first_pose_group.id,) self.state.target_status = TargetStatus.EMPTY self._default_group_initialized = True self._sync_group_selection_state() @@ -196,7 +193,7 @@ def refresh(self) -> None: def _list_robots(self) -> list[RobotName]: return list(self.manipulation_module.list_robots()) - def _list_planning_groups(self) -> list[PlanningGroupInfo]: + def _list_planning_groups(self) -> list[PlanningGroup]: return self.manipulation_module.list_planning_groups() def _get_robot_info(self, robot_name: RobotName) -> RobotInfo | None: @@ -356,7 +353,7 @@ def _ensure_scene_controls(self) -> None: remove() for group_id in active_pose_groups: group = groups.get(group_id) - if group is None or not bool(group["has_pose_target"]): + if group is None or not bool(group.has_pose_target): continue handle_key = f"ee_control:{group_id}" if handle_key in self._handles: @@ -398,13 +395,13 @@ def _build_joint_slider_handles(self, gui: GuiApi) -> None: group = groups.get(group_id) if group is None: continue - config = self.manipulation_module.get_robot_config(str(group["robot_name"])) - current = self._get_current_joint_state(str(group["robot_name"])) - current_by_name = joint_values_by_name(str(group["robot_name"]), current) + config = self.manipulation_module.get_robot_config(str(group.robot_name)) + current = self._get_current_joint_state(str(group.robot_name)) + current_by_name = joint_values_by_name(str(group.robot_name), current) joint_limits_lower = config.joint_limits_lower if config is not None else None joint_limits_upper = config.joint_limits_upper if config is not None else None for index, (global_name, local_name) in enumerate( - zip(group["joint_names"], group["local_joint_names"], strict=False) + zip(group.joint_names, group.local_joint_names, strict=False) ): joint_name = str(global_name) local = str(local_name) @@ -443,7 +440,7 @@ def _target_set_from_sliders(self) -> dict[PlanningGroupID, JointState] | None: if group is None: self._set_error(f"Unknown planning group: {group_id}") return None - names = [str(name) for name in group["joint_names"]] + names = [str(name) for name in group.joint_names] positions: list[float] = [] for name in names: handle = self._joint_sliders.get(name) @@ -462,7 +459,7 @@ def _split_target_joints_by_group(self, target_joints: JointState) -> None: group = groups.get(group_id) if group is None: continue - names = [str(name) for name in group["joint_names"]] + names = [str(name) for name in group.joint_names] if not all(name in positions_by_name for name in names): continue self.state.group_joint_targets[group_id] = JointState( @@ -484,13 +481,13 @@ def _remove_panel_handles(self) -> None: remove() self._handles.pop(key, None) - def _sync_group_selector(self, groups: list[PlanningGroupInfo]) -> None: + def _sync_group_selector(self, groups: list[PlanningGroup]) -> None: seen_keys: set[str] = set() selected = set(self.state.selected_group_ids) for group in sorted( - groups, key=lambda item: (not bool(item["has_pose_target"]), str(item["id"])) + groups, key=lambda item: (not bool(item.has_pose_target), str(item.id)) ): - group_id = str(group["id"]) + group_id = str(group.id) key = f"group:{group_id}" seen_keys.add(key) handle = self._handles.get(key) @@ -541,10 +538,10 @@ def _toggle_group_selected(self, group_id: PlanningGroupID) -> None: def _select_all_manipulators(self) -> None: groups = self._list_planning_groups() manipulator_groups = [ - str(group["id"]) for group in groups if str(group["name"]) == "manipulator" + str(group.id) for group in groups if str(group.group_name) == "manipulator" ] self.state.selected_group_ids = tuple( - manipulator_groups or [str(group["id"]) for group in groups] + manipulator_groups or [str(group.id) for group in groups] ) self._sync_group_selection_state() self._initialize_selected_group_targets() @@ -563,15 +560,15 @@ def _clear_group_selection(self) -> None: self._build_joint_sliders() self.refresh() - def _group_info_by_id(self) -> dict[PlanningGroupID, PlanningGroupInfo]: - return {str(group["id"]): group for group in self._list_planning_groups()} + def _group_info_by_id(self) -> dict[PlanningGroupID, PlanningGroup]: + return {str(group.id): group for group in self._list_planning_groups()} def _sync_selected_robot_from_groups(self) -> None: groups = self._group_info_by_id() first_group = ( groups.get(self.state.selected_group_ids[0]) if self.state.selected_group_ids else None ) - self.state.selected_robot = None if first_group is None else str(first_group["robot_name"]) + self.state.selected_robot = None if first_group is None else str(first_group.robot_name) def _sync_group_selection_state(self) -> None: self._sync_selected_robot_from_groups() @@ -582,7 +579,7 @@ def _selected_pose_group_ids(self) -> tuple[PlanningGroupID, ...]: return tuple( group_id for group_id in self.state.selected_group_ids - if (group := groups.get(group_id)) is not None and bool(group["has_pose_target"]) + if (group := groups.get(group_id)) is not None and bool(group.has_pose_target) ) def _selected_auxiliary_group_ids(self) -> tuple[PlanningGroupID, ...]: @@ -590,7 +587,7 @@ def _selected_auxiliary_group_ids(self) -> tuple[PlanningGroupID, ...]: return tuple( group_id for group_id in self.state.selected_group_ids - if (group := groups.get(group_id)) is not None and not bool(group["has_pose_target"]) + if (group := groups.get(group_id)) is not None and not bool(group.has_pose_target) ) def _active_pose_targets(self) -> dict[PlanningGroupID, Pose]: @@ -620,12 +617,12 @@ def _initialize_selected_group_targets(self) -> None: group = groups.get(group_id) if group is None: continue - current = self._get_current_joint_state(str(group["robot_name"])) + current = self._get_current_joint_state(str(group.robot_name)) if current is None: continue - current_by_name = joint_values_by_name(str(group["robot_name"]), current) - names = [str(name) for name in group["joint_names"]] - local_names = [str(name) for name in group["local_joint_names"]] + current_by_name = joint_values_by_name(str(group.robot_name), current) + names = [str(name) for name in group.joint_names] + local_names = [str(name) for name in group.local_joint_names] positions = [ float(current_by_name.get(global_name, current_by_name.get(local, 0.0))) for global_name, local in zip(names, local_names, strict=False) @@ -633,8 +630,8 @@ def _initialize_selected_group_targets(self) -> None: self.state.group_joint_targets[group_id] = JointState( {"name": names, "position": positions} ) - if bool(group["has_pose_target"]) and group_id not in self.state.pose_targets: - pose = self._get_ee_pose(str(group["robot_name"])) + if bool(group.has_pose_target) and group_id not in self.state.pose_targets: + pose = self._get_ee_pose(str(group.robot_name)) if pose is not None: self.state.pose_targets[group_id] = pose self.state.group_poses[group_id] = pose @@ -662,16 +659,16 @@ def _current_snapshot_by_group(self) -> dict[PlanningGroupID, list[float]]: group = groups.get(group_id) if group is None: continue - current = self._get_current_joint_state(str(group["robot_name"])) + current = self._get_current_joint_state(str(group.robot_name)) if current is None: continue - current_by_name = joint_values_by_name(str(group["robot_name"]), current) + current_by_name = joint_values_by_name(str(group.robot_name), current) snapshot[group_id] = [ float( current_by_name.get(str(global_name), current_by_name.get(str(local_name), 0.0)) ) for global_name, local_name in zip( - group["joint_names"], group["local_joint_names"], strict=False + group.joint_names, group.local_joint_names, strict=False ) ] return snapshot @@ -708,13 +705,13 @@ def _apply_preset(self, preset: str) -> None: groups = [ group for group in self._list_planning_groups() - if group["id"] in self.state.selected_group_ids + if group.id in self.state.selected_group_ids ] for group in groups: - robot_name = str(group["robot_name"]) + robot_name = str(group.robot_name) values_by_name = self._preset_values_by_name(preset, robot_name) - global_names = [str(name) for name in group["joint_names"]] - local_names = [str(name) for name in group["local_joint_names"]] + global_names = [str(name) for name in group.joint_names] + local_names = [str(name) for name in group.local_joint_names] values = [ float(values_by_name.get(local_name, values_by_name.get(global_name, 0.0))) for local_name, global_name in zip(local_names, global_names, strict=False) @@ -731,7 +728,7 @@ def _selected_robot_names(self) -> tuple[str, ...]: group = groups.get(group_id) if group is None: continue - robot_name = str(group["robot_name"]) + robot_name = str(group.robot_name) if robot_name not in names: names.append(robot_name) return tuple(names) @@ -828,7 +825,7 @@ def _move_joint_target_visuals(self) -> None: group = groups.get(group_id) if group is None: continue - robot_name = str(group["robot_name"]) + robot_name = str(group.robot_name) robot_id = self.manipulation_module.robot_id_for_name(robot_name) config = self.manipulation_module.get_robot_config(robot_name) if robot_id is None or config is None: @@ -836,9 +833,9 @@ def _move_joint_target_visuals(self) -> None: local_positions = dict(zip(target.name, target.position, strict=False)) joints = [ float(local_positions.get(str(global_name), 0.0)) - for global_name in group["joint_names"] + for global_name in group.joint_names ] - self.scene.set_target_joints(str(robot_id), group["local_joint_names"], joints) + self.scene.set_target_joints(str(robot_id), list(group.local_joint_names), joints) def _sync_target_ghost_visibility(self) -> None: if self.scene is None: @@ -849,7 +846,7 @@ def _sync_target_ghost_visibility(self) -> None: group = groups.get(group_id) if group is None: continue - robot_id = self.manipulation_module.robot_id_for_name(str(group["robot_name"])) + robot_id = self.manipulation_module.robot_id_for_name(str(group.robot_name)) if robot_id is not None: active_robot_ids.add(str(robot_id)) set_target_active = getattr(self.scene, "set_target_active", None) @@ -932,7 +929,7 @@ def _sync_pose_targets_from_group_poses(self) -> None: updated_group_ids: list[PlanningGroupID] = [] for group_id, pose in self.state.group_poses.items(): group = groups.get(group_id) - if group is None or not bool(group["has_pose_target"]): + if group is None or not bool(group.has_pose_target): continue if group_id not in self._selected_pose_group_ids(): continue diff --git a/dimos/manipulation/visualization/viser/panel_backend.py b/dimos/manipulation/visualization/viser/panel_backend.py index 4934a9eb6c..fb05695daf 100644 --- a/dimos/manipulation/visualization/viser/panel_backend.py +++ b/dimos/manipulation/visualization/viser/panel_backend.py @@ -17,11 +17,9 @@ from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, cast +from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName -from dimos.manipulation.visualization.types import ( - PlanningGroupInfo, - TargetSetEvaluation, -) +from dimos.manipulation.visualization.types import TargetSetEvaluation from dimos.manipulation.visualization.viser.state import FeasibilityStatus from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -62,7 +60,7 @@ def is_state_stale( def get_ee_pose( world_monitor: WorldMonitor, manipulation_module: ManipulationModule, - groups: Sequence[PlanningGroupInfo], + groups: Sequence[PlanningGroup], robot_name: RobotName, joint_state: JointState | None = None, ) -> Pose | None: @@ -193,11 +191,11 @@ def evaluate_global_target_set( def primary_pose_group_id( - groups: Sequence[PlanningGroupInfo], robot_name: RobotName + groups: Sequence[PlanningGroup], robot_name: RobotName ) -> PlanningGroupID | None: for group in groups: - if str(group["robot_name"]) == robot_name and bool(group["has_pose_target"]): - return str(group["id"]) + if group.robot_name == robot_name and group.has_pose_target: + return group.id return None diff --git a/dimos/manipulation/visualization/viser/test_operation_worker.py b/dimos/manipulation/visualization/viser/test_operation_worker.py index 8e4dd39c53..8367d05a19 100644 --- a/dimos/manipulation/visualization/viser/test_operation_worker.py +++ b/dimos/manipulation/visualization/viser/test_operation_worker.py @@ -22,7 +22,8 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") -from dimos.manipulation.visualization.types import PlanningGroupInfo, RobotInfo +from dimos.manipulation.planning.groups.models import PlanningGroup +from dimos.manipulation.visualization.types import RobotInfo from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig from dimos.manipulation.visualization.viser.gui import ViserPanelGui from dimos.manipulation.visualization.viser.state import ( @@ -129,7 +130,7 @@ def get_state(self) -> str: def get_robot_info(self, robot_name: str) -> RobotInfo | None: return None - def list_planning_groups(self) -> list[PlanningGroupInfo]: + def list_planning_groups(self) -> list[PlanningGroup]: return [] def get_current_joint_state(self, robot_name: str) -> None: diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index d62255ffea..a450a72079 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -25,13 +25,14 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") +from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import ( CollisionCheckResult, ForwardKinematicsResult, IKResult, ) -from dimos.manipulation.visualization.types import PlanningGroupInfo, RobotInfo +from dimos.manipulation.visualization.types import RobotInfo from dimos.manipulation.visualization.viser import scene as scene_module from dimos.manipulation.visualization.viser.animation import ( GroupPreviewAnimation, @@ -388,19 +389,18 @@ def make_planning_group_info( *, group_name: str = "manipulator", has_pose_target: bool = True, -) -> dict[str, object]: +) -> PlanningGroup: joint_names = [str(name) for name in config.joint_names] - return { - "id": f"{robot_name}:{group_name}", - "name": group_name, - "robot_name": robot_name, - "joint_names": [f"{robot_name}/{name}" for name in joint_names], - "local_joint_names": joint_names, - "base_link": str(config.base_link), - "tip_link": str(config.end_effector_link) if has_pose_target else None, - "has_pose_target": has_pose_target, - "source": "fallback", - } + return PlanningGroup( + id=f"{robot_name}:{group_name}", + group_name=group_name, + robot_name=robot_name, + joint_names=tuple(f"{robot_name}/{name}" for name in joint_names), + local_joint_names=tuple(joint_names), + base_link=str(config.base_link), + tip_link=str(config.end_effector_link) if has_pose_target else None, + source="fallback", + ) class FakeManipulationModule(SimpleNamespace): @@ -439,9 +439,7 @@ def get_robot_info(self, robot_name: str) -> RobotInfo | None: if planning_groups is None: planning_groups = [make_planning_group_info(robot_name, config)] else: - planning_groups = [ - group for group in planning_groups if str(group["robot_name"]) == robot_name - ] + planning_groups = [group for group in planning_groups if group.robot_name == robot_name] return { "name": config.name, "world_robot_id": self.robot_id_for_name(robot_name) or robot_name, @@ -458,8 +456,8 @@ def get_robot_info(self, robot_name: str) -> RobotInfo | None: "init_joints": list(init.position) if init is not None else None, } - def list_planning_groups(self) -> list[PlanningGroupInfo]: - groups: list[PlanningGroupInfo] = [] + def list_planning_groups(self) -> list[PlanningGroup]: + groups: list[PlanningGroup] = [] for robot_name in self.list_robots(): info = self.get_robot_info(robot_name) if info is not None: @@ -516,8 +514,8 @@ def forward_kinematics( world_monitor = getattr(self, "_world_monitor", None) pose = None if world_monitor is not None and robot_id is not None: - if hasattr(world_monitor, "get_group_pose"): - pose = world_monitor.get_group_pose(group_id, target_joints) + if hasattr(world_monitor, "get_group_ee_pose"): + pose = world_monitor.get_group_ee_pose(group_id, target_joints) else: pose = world_monitor.get_ee_pose(robot_id, target_joints) return ForwardKinematicsResult( diff --git a/dimos/robot/catalog/openarm.py b/dimos/robot/catalog/openarm.py deleted file mode 100644 index fb57e013b5..0000000000 --- a/dimos/robot/catalog/openarm.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""OpenArm v10 robot configurations.""" - -from __future__ import annotations - -from typing import Any - -from dimos.robot.config import RobotConfig -from dimos.utils.data import LfsPath - -# Collision exclusion pairs — structural mesh overlaps in the OpenArm URDF. -# link5 and link7 collision meshes overlap by ~3mm at zero pose (and every -# other pose) — same pattern as R1 Pro's non-adjacent link overlap. -OPENARM_COLLISION_EXCLUSIONS: list[tuple[str, str]] = [ - ("openarm_left_link5", "openarm_left_link7"), - ("openarm_right_link5", "openarm_right_link7"), -] - -# LFS-backed: data/.lfs/openarm_description.tar.gz extracts to data/openarm_description/ -_OPENARM_PKG = LfsPath("openarm_description") -_OPENARM_MODEL_PATH = _OPENARM_PKG / "urdf/robot/openarm_v10_bimanual.urdf" -# Per-side URDFs: extracted from bimanual expansion, only one arm + torso each. -# Avoids phantom-arm collisions when Drake loads both sides into one world. -_OPENARM_LEFT_MODEL = _OPENARM_PKG / "urdf/robot/openarm_v10_left.urdf" -_OPENARM_RIGHT_MODEL = _OPENARM_PKG / "urdf/robot/openarm_v10_right.urdf" - -# Pre-expanded single-arm URDF for Pinocchio FK (keyboard teleop, IK, etc.) -OPENARM_V10_FK_MODEL = _OPENARM_PKG / "urdf/robot/openarm_v10_single.urdf" - - -def openarm_arm( - side: str = "left", - name: str | None = None, - *, - adapter_type: str = "mock", - address: str | None = None, - **overrides: Any, -) -> RobotConfig: - """OpenArm v10 config for one side. Uses per-side URDF (arm + torso only).""" - if side not in ("left", "right"): - raise ValueError(f"side must be 'left' or 'right', got {side!r}") - - resolved_name = name or f"{side}_arm" - # Pre-expanded bimanual URDF uses openarm_{side}_* naming. - joint_names = [f"openarm_{side}_joint{i}" for i in range(1, 8)] - ee_link = f"openarm_{side}_link7" - - defaults: dict[str, Any] = { - "name": resolved_name, - "model_path": _OPENARM_LEFT_MODEL if side == "left" else _OPENARM_RIGHT_MODEL, - "end_effector_link": ee_link, - "adapter_type": adapter_type, - "address": address, - "joint_names": joint_names, - "base_link": "openarm_body_link0", - "home_joints": [0.0] * 7, - "base_pose": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], - "package_paths": {"openarm_description": _OPENARM_PKG}, - "collision_exclusion_pairs": OPENARM_COLLISION_EXCLUSIONS, - "auto_convert_meshes": True, - "max_velocity": 0.5, - "max_acceleration": 1.0, - "adapter_kwargs": {"side": side}, - } - # Merge adapter_kwargs rather than replace, so callers can add keys - # (e.g. auto_set_mit_mode) without clobbering the catalog's "side". - if "adapter_kwargs" in overrides: - defaults["adapter_kwargs"] = { - **defaults["adapter_kwargs"], - **overrides.pop("adapter_kwargs"), - } - defaults.update(overrides) - return RobotConfig(**defaults) - - -def openarm_single( - name: str = "arm", - *, - adapter_type: str = "mock", - address: str | None = None, - **overrides: Any, -) -> RobotConfig: - """Single-arm config (keyboard teleop, cartesian IK). Use openarm_arm() for bimanual.""" - defaults: dict[str, Any] = { - "name": name, - "model_path": OPENARM_V10_FK_MODEL, - "end_effector_link": "openarm_left_link7", - "adapter_type": adapter_type, - "address": address, - "joint_names": [f"openarm_left_joint{i}" for i in range(1, 8)], - "base_link": "openarm_body_link0", - "home_joints": [0.0] * 7, - "package_paths": {"openarm_description": _OPENARM_PKG}, - "auto_convert_meshes": True, - "max_velocity": 0.5, - "max_acceleration": 1.0, - "adapter_kwargs": {"side": "left"}, - } - defaults.update(overrides) - return RobotConfig(**defaults) - - -__all__ = ["OPENARM_V10_FK_MODEL", "openarm_arm", "openarm_single"] From 5345204eebc2d74883c5a87b7834767cb593b251 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 20:16:04 -0700 Subject: [PATCH 064/110] refactor: use direct pink imports --- .../planning/kinematics/pink_ik.py | 65 +++++++------------ 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index 06e53fbd1c..3397fb20b9 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -309,14 +309,12 @@ def _solve_single( position_tolerance: float, orientation_tolerance: float, ) -> IKResult: - pink_module = _pink() - pinocchio_module = _pinocchio() - configuration = pink_module.Configuration( - robot_context.model, robot_context.data, seed_q.copy() - ) - target_se3 = _matrix_to_se3(pinocchio_module, target_model) + assert pink is not None + assert pinocchio is not None + configuration = pink.Configuration(robot_context.model, robot_context.data, seed_q.copy()) + target_se3 = _matrix_to_se3(pinocchio, target_model) - frame_task = pink_module.tasks.FrameTask( + frame_task = pink.tasks.FrameTask( robot_context.frame_name, position_cost=self.config.position_cost, orientation_cost=self.config.orientation_cost, @@ -327,7 +325,7 @@ def _solve_single( tasks: list[Any] = [frame_task] if self.config.posture_cost > 0.0: - posture_task = pink_module.tasks.PostureTask(cost=self.config.posture_cost) + posture_task = pink.tasks.PostureTask(cost=self.config.posture_cost) posture_task.set_target_from_configuration(configuration) tasks.append(posture_task) @@ -351,7 +349,7 @@ def _solve_single( iteration + 1, ) - velocity = pink_module.solve_ik( + velocity = pink.solve_ik( configuration, tasks, self.config.dt, @@ -392,27 +390,27 @@ def _solve_multi_frame( orientation_tolerance: float, ) -> IKResult: """Solve multiple frame tasks for one robot model.""" - pink_module = _pink() - pinocchio_module = _pinocchio() + assert pink is not None + assert pinocchio is not None primary_context = robot_contexts[0] - configuration = pink_module.Configuration( + configuration = pink.Configuration( primary_context.model, primary_context.data, seed_q.copy() ) tasks: list[Any] = [] for context, target_model in zip(robot_contexts, target_models, strict=True): - frame_task = pink_module.tasks.FrameTask( + frame_task = pink.tasks.FrameTask( context.frame_name, position_cost=self.config.position_cost, orientation_cost=self.config.orientation_cost, lm_damping=self.config.lm_damping, gain=self.config.gain, ) - frame_task.set_target(_matrix_to_se3(pinocchio_module, target_model)) + frame_task.set_target(_matrix_to_se3(pinocchio, target_model)) tasks.append(frame_task) if self.config.posture_cost > 0.0: - posture_task = pink_module.tasks.PostureTask(cost=self.config.posture_cost) + posture_task = pink.tasks.PostureTask(cost=self.config.posture_cost) posture_task.set_target_from_configuration(configuration) tasks.append(posture_task) @@ -440,7 +438,7 @@ def _solve_multi_frame( iteration + 1, ) - velocity = pink_module.solve_ik( + velocity = pink.solve_ik( configuration, tasks, self.config.dt, @@ -491,13 +489,13 @@ def _get_robot_context( def _build_robot_context( self, config: RobotModelConfig, frame_name: str | None = None ) -> _PinkRobotContext: - pinocchio_module = _pinocchio() + assert pinocchio is not None model_path = Path(config.model_path).resolve() if not model_path.exists(): raise FileNotFoundError(f"Robot model not found: {model_path}") if model_path.suffix == ".xml": - model = pinocchio_module.buildModelFromMJCF(str(model_path)) + model = pinocchio.buildModelFromMJCF(str(model_path)) else: prepared_path = prepare_urdf_for_drake( urdf_path=model_path, @@ -508,8 +506,8 @@ def _build_robot_context( if config.strip_model_world_joint else None, ) - model = pinocchio_module.buildModelFromUrdf(str(prepared_path)) - model = _lock_uncontrolled_model_joints(pinocchio_module, model, config) + model = pinocchio.buildModelFromUrdf(str(prepared_path)) + model = _lock_uncontrolled_model_joints(pinocchio, model, config) data = model.createData() target_frame = frame_name or config.end_effector_link @@ -533,7 +531,8 @@ def _initial_q( upper_limits: NDArray[np.float64], attempt: int, ) -> NDArray[np.float64]: - neutral = _pinocchio().neutral(context.model) + assert pinocchio is not None + neutral = pinocchio.neutral(context.model) q = np.array(neutral, dtype=np.float64) if attempt == 0: @@ -553,9 +552,9 @@ def _q_to_dimos_positions( def _current_frame_matrix( self, context: _PinkRobotContext, q: NDArray[np.float64] ) -> NDArray[np.float64]: - pinocchio_module = _pinocchio() - pinocchio_module.forwardKinematics(context.model, context.data, q) - pinocchio_module.updateFramePlacements(context.model, context.data) + assert pinocchio is not None + pinocchio.forwardKinematics(context.model, context.data, q) + pinocchio.updateFramePlacements(context.model, context.data) placement = context.data.oMf[context.frame_id] matrix: NDArray[np.float64] = np.eye(4) matrix[:3, :3] = np.asarray(placement.rotation, dtype=np.float64) @@ -590,24 +589,6 @@ def _check_optional_dependencies(solver: str) -> None: ) -def _pink() -> Any: - if _PINK_IMPORT_ERROR is not None or pink is None: - raise PinkIKDependencyError( - "Pink IK backend requires Pink. " - f"{_MANIPULATION_EXTRA_HINT} PyPI package: pin-pink; import name: pink." - ) from _PINK_IMPORT_ERROR - return pink - - -def _pinocchio() -> Any: - if _PINK_IMPORT_ERROR is not None or pinocchio is None: - raise PinkIKDependencyError( - "Pink IK backend requires Pinocchio (import name 'pinocchio'). " - f"{_MANIPULATION_EXTRA_HINT}" - ) from _PINK_IMPORT_ERROR - return pinocchio - - def _build_joint_mapping(model: Any, config: RobotModelConfig) -> _JointMapping: idx_q: list[int] = [] model_joint_names: list[str] = [] From fa35c31536d39162c24f036c5df24ef967410680 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 20:36:00 -0700 Subject: [PATCH 065/110] fix: update viser collision target ghost --- dimos/manipulation/visualization/viser/gui.py | 21 +++--- .../visualization/viser/panel_backend.py | 29 +++++++- .../viser/test_viser_visualization.py | 67 ++++++++++++++++++- 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 0f5c29690b..22a6de12ff 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -35,6 +35,7 @@ is_state_stale, joint_values_by_name, pose_from_transform_values, + update_target_visual_state, ) from dimos.manipulation.visualization.viser.runtime import VISER_INSTALL_HINT from dimos.manipulation.visualization.viser.scene import ViserManipulationScene @@ -889,10 +890,11 @@ def _apply_target_evaluation_result( ) self.state.error = "" if success and collision_free else self.state.feasibility.message target_joints = result.get("target_joints") or result.get("joint_state") - if isinstance(target_joints, JointState) and success and collision_free: + if isinstance(target_joints, JointState): self.state.target_joints = JointState(target_joints) - self.state.last_valid_target_joints = JointState(target_joints) self._split_target_joints_by_group(target_joints) + if success and collision_free: + self.state.last_valid_target_joints = JointState(target_joints) group_poses = result.get("group_poses", {}) if isinstance(group_poses, dict): self.state.group_poses = { @@ -900,14 +902,14 @@ def _apply_target_evaluation_result( for group_id, pose in group_poses.items() if isinstance(pose, Pose) } - if request.source == "joints" and success and collision_free: + if request.source == "joints" and isinstance(target_joints, JointState): self._sync_pose_targets_from_group_poses() group_diagnostics = result.get("group_diagnostics", {}) if isinstance(group_diagnostics, dict): self.state.group_diagnostics = { str(group_id): str(message) for group_id, message in group_diagnostics.items() } - if request.source == "cartesian" and success and collision_free: + if request.source == "cartesian" and isinstance(target_joints, JointState): self._sync_controls_from_targets() self._update_target_visual_state() self.refresh() @@ -991,10 +993,13 @@ def _update_control_state(self) -> None: def _update_target_visual_state(self) -> None: if self.scene is None: return - for group_id in self.state.selected_group_ids: - self.scene.set_target_visual_state( - group_id, self.state.feasibility.status == FeasibilityStatus.FEASIBLE - ) + update_target_visual_state( + self.scene, + self._group_info_by_id(), + self.state.selected_group_ids, + self.manipulation_module.robot_id_for_name, + self.state.feasibility.status == FeasibilityStatus.FEASIBLE, + ) def _submit_plan(self) -> None: if self._closed: diff --git a/dimos/manipulation/visualization/viser/panel_backend.py b/dimos/manipulation/visualization/viser/panel_backend.py index fb05695daf..6fbc51a408 100644 --- a/dimos/manipulation/visualization/viser/panel_backend.py +++ b/dimos/manipulation/visualization/viser/panel_backend.py @@ -14,7 +14,7 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import TYPE_CHECKING, cast from dimos.manipulation.planning.groups.models import PlanningGroup @@ -239,3 +239,30 @@ def feasibility_status( if normalized in {"NO_SOLUTION", "SINGULARITY", "JOINT_LIMITS", "TIMEOUT"}: return FeasibilityStatus.IK_FAILED return FeasibilityStatus.INVALID + + +def update_target_visual_state( + scene: object, + groups: Mapping[PlanningGroupID, PlanningGroup], + selected_group_ids: Sequence[PlanningGroupID], + robot_id_for_name: Callable[[RobotName], object | None], + feasible: bool, +) -> None: + set_visual_state = getattr(scene, "set_target_visual_state", None) + if not callable(set_visual_state): + return + updated: set[str] = set() + for group_id in selected_group_ids: + group_id_str = str(group_id) + if group_id_str not in updated: + set_visual_state(group_id_str, feasible) + updated.add(group_id_str) + group = groups.get(group_id_str) + if group is None: + continue + robot_id = robot_id_for_name(str(group.robot_name)) + robot_id_str = None if robot_id is None else str(robot_id) + if robot_id_str is None or robot_id_str in updated: + continue + set_visual_state(robot_id_str, feasible) + updated.add(robot_id_str) diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index a450a72079..42d78c068f 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -1667,6 +1667,70 @@ def test_gui_cartesian_ik_result_does_not_rewrite_active_gizmo( assert gui.state.group_poses[DEFAULT_GROUP_ID] is solved_pose +def test_gui_cartesian_collision_still_updates_target_ghost_red( + make_panel: Callable[..., ViserPanelGui], +) -> None: + current = FakeJointState(["j1", "j2"], position=[0.0, 0.0]) + config = make_robot_config(joint_names=["j1", "j2"], home_joints=[0.5, 0.6]) + module = FakeManipulationModule(_robots={"arm": ("robot-1", config, None)}) + world_monitor = SimpleNamespace( + get_current_joint_state=lambda robot_id: current, + is_state_stale=lambda robot_id, max_age=1.0: False, + is_state_valid=lambda robot_id, joint_state: False, + get_ee_pose=lambda robot_id, joint_state=None: None, + ) + module_context = (world_monitor, module) + target_joint_updates = [] + target_pose_updates = [] + visual_states = [] + scene = SimpleNamespace( + has_reference_grid=lambda: False, + ensure_target_controls=lambda *args: object(), + set_target_joints=lambda *args: target_joint_updates.append(args) or True, + set_target_pose=lambda *args: target_pose_updates.append(args), + set_target_visual_state=lambda *args: visual_states.append(args), + ) + gui = make_panel( + FakeGuiServer(), module_context, ViserVisualizationConfig(panel_enabled=True), scene + ) + dragged_pose = Pose({"position": [0.1, 0.2, 0.3], "orientation": [0.0, 0.0, 0.0, 1.0]}) + solved_pose = Pose({"position": [0.4, 0.5, 0.6], "orientation": [0.0, 0.0, 0.0, 1.0]}) + gui.state.cartesian_target = dragged_pose + gui.state.pose_targets[DEFAULT_GROUP_ID] = dragged_pose + target_joint_updates.clear() + target_pose_updates.clear() + visual_states.clear() + request = TargetEvaluationRequest( + sequence_id=1, source="cartesian", group_ids=(DEFAULT_GROUP_ID,) + ) + gui.state.latest_sequence_id = 1 + + gui._apply_target_evaluation_result( + request, + { + "success": False, + "status": "COLLISION", + "message": "Target is in collision", + "collision_free": False, + "target_joints": joints_from_values(["arm/j1", "arm/j2"], [1.0, 2.0]), + "group_poses": {DEFAULT_GROUP_ID: solved_pose}, + }, + ) + + assert gui.state.target_status == TargetStatus.INFEASIBLE + assert gui.state.feasibility.status == FeasibilityStatus.COLLISION + assert gui.state.target_joints is not None + assert list(gui.state.target_joints.position) == [1.0, 2.0] + assert gui.state.last_valid_target_joints is None + assert [gui._joint_sliders[name].value for name in ("arm/j1", "arm/j2")] == [1.0, 2.0] + assert target_joint_updates[-1] == ("robot-1", ["j1", "j2"], [1.0, 2.0]) + assert (DEFAULT_GROUP_ID, False) in visual_states + assert ("robot-1", False) in visual_states + assert target_pose_updates == [] + assert gui.state.pose_targets[DEFAULT_GROUP_ID] is dragged_pose + assert gui.state.group_poses[DEFAULT_GROUP_ID] is solved_pose + + def test_gui_can_disable_collision_check_for_cartesian_target_evaluation( make_panel: Callable[..., ViserPanelGui], ) -> None: @@ -1753,7 +1817,8 @@ def test_gui_collision_evaluation_marks_target_infeasible_and_colors_scene( assert gui.state.target_status == TargetStatus.INFEASIBLE assert gui.state.feasibility.status == FeasibilityStatus.COLLISION assert gui.state.error == "Target is in collision" - assert visual_states[-1] == (DEFAULT_GROUP_ID, False) + assert (DEFAULT_GROUP_ID, False) in visual_states + assert ("robot-1", False) in visual_states def test_gui_safe_execute_requires_fresh_matching_plan_and_clear_resets_path( From a27e822d6dc4f45e1857603cf27daa570b443380 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 20:46:36 -0700 Subject: [PATCH 066/110] chore: rpc for list planning group --- dimos/manipulation/manipulation_module.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 2d4adf462a..76328eb887 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -625,6 +625,7 @@ def _planning_group_models(self) -> list[PlanningGroup]: return [] return list(self._world_monitor.planning_groups.list()) + @rpc def list_planning_groups(self) -> list[PlanningGroup]: """Return all planning groups.""" return self._planning_group_models() From a61b46be4e35f09c05027343ae1103c2810597db Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 20:51:40 -0700 Subject: [PATCH 067/110] openspec remove --- CONTEXT.md | 101 ---- .../add-planning-groups/.openspec.yaml | 2 - .../changes/add-planning-groups/design.md | 555 ------------------ openspec/changes/add-planning-groups/docs.md | 45 -- .../changes/add-planning-groups/proposal.md | 53 -- .../manipulation-planning-groups/spec.md | 216 ------- openspec/changes/add-planning-groups/tasks.md | 92 --- .../.openspec.yaml | 2 - .../add-viser-planning-target-set/design.md | 139 ----- .../add-viser-planning-target-set/docs.md | 32 - .../add-viser-planning-target-set/proposal.md | 53 -- .../manipulation-planning-groups/spec.md | 71 --- .../specs/viser-planning-target-set/spec.md | 115 ---- .../add-viser-planning-target-set/tasks.md | 45 -- openspec/config.yaml | 45 -- openspec/schemas/dimos-capability/schema.yaml | 128 ---- .../dimos-capability/templates/design.md | 35 -- .../dimos-capability/templates/docs.md | 19 - .../dimos-capability/templates/proposal.md | 32 - .../dimos-capability/templates/spec.md | 16 - .../dimos-capability/templates/tasks.md | 15 - 21 files changed, 1811 deletions(-) delete mode 100644 CONTEXT.md delete mode 100644 openspec/changes/add-planning-groups/.openspec.yaml delete mode 100644 openspec/changes/add-planning-groups/design.md delete mode 100644 openspec/changes/add-planning-groups/docs.md delete mode 100644 openspec/changes/add-planning-groups/proposal.md delete mode 100644 openspec/changes/add-planning-groups/specs/manipulation-planning-groups/spec.md delete mode 100644 openspec/changes/add-planning-groups/tasks.md delete mode 100644 openspec/changes/add-viser-planning-target-set/.openspec.yaml delete mode 100644 openspec/changes/add-viser-planning-target-set/design.md delete mode 100644 openspec/changes/add-viser-planning-target-set/docs.md delete mode 100644 openspec/changes/add-viser-planning-target-set/proposal.md delete mode 100644 openspec/changes/add-viser-planning-target-set/specs/manipulation-planning-groups/spec.md delete mode 100644 openspec/changes/add-viser-planning-target-set/specs/viser-planning-target-set/spec.md delete mode 100644 openspec/changes/add-viser-planning-target-set/tasks.md delete mode 100644 openspec/config.yaml delete mode 100644 openspec/schemas/dimos-capability/schema.yaml delete mode 100644 openspec/schemas/dimos-capability/templates/design.md delete mode 100644 openspec/schemas/dimos-capability/templates/docs.md delete mode 100644 openspec/schemas/dimos-capability/templates/proposal.md delete mode 100644 openspec/schemas/dimos-capability/templates/spec.md delete mode 100644 openspec/schemas/dimos-capability/templates/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 288e857608..0000000000 --- a/CONTEXT.md +++ /dev/null @@ -1,101 +0,0 @@ -# DimOS Robotics Language - -Shared vocabulary for DimOS robotics concepts. These terms define domain language, not implementation details. - -## Language - -**Robot Name**: -A stable planning-domain identity for a concrete robot/model instance, used in public planning group and flat joint-name scoping. -_Avoid_: World robot ID, hardware ID, namespace - -**World Robot ID**: -An internal planning-world handle for a robot after it has been added to a backend world. -_Avoid_: Robot name, hardware ID, joint namespace - -**Hardware ID**: -A control-layer routing identity for a hardware component. For manipulator robots, it normally matches the robot name at the coordinator boundary. -_Avoid_: Robot name when discussing planning semantics, world robot ID - -**Planning Group**: -A named selectable serial kinematic chain of robot joints used as the unit of manipulation planning. After binding to a robot name, it is identified by a planning group ID and remains independent of backend world robot IDs. -_Avoid_: Move group, movegroup - -**Planning Group Definition**: -The model-level declaration of a planning group before it is bound to a concrete robot in a planning world. -_Avoid_: Runtime group, robot ID - -**End-Effector Association**: -Separate metadata used for pose-targeted operations. For a planning group defined by a chain, the end-effector link is the chain tip. For a planning group defined only by joints, there is no end-effector link. Documentation should use group end-effector language for this pose-target frame; operation names may use standard robotics terms such as forward kinematics and inverse kinematics when the planning-group scope is explicit in the parameters. -_Avoid_: Planning group definition, group target-frame pose - -**Group Forward Kinematics**: -A group-centric query that computes the end-effector pose for one pose-targetable planning group. Only that planning group's joints affect the result; other planning-world joints do not need to be supplied or filled. -_Avoid_: Planning-world FK, collision-state projection - -**Planning Group Selection**: -The set of one or more planning groups chosen for a planning request. -_Avoid_: Composite group - -**Planning Target Set**: -The atomic manipulation UI/planning state built on top of a planning group selection: selected planning groups, target authoring state, combined IK joint target, whole-set feasibility, and generated plan. Per-group UI panels are views into this target set, not independent planning states. -_Avoid_: Independent group cards, per-robot plan state - -**Auxiliary Planning Group**: -A planning group selected to participate in a planning target set without receiving a direct end-effector pose target in that request. It is solved, checked, planned, previewed, and executed with the whole target set; it simply has no assigned target gizmo. A planning group may be auxiliary in one request and directly targeted in another. -_Avoid_: Joint-only group, intrinsic auxiliary group - -**Coordinated Planning Problem**: -A planning request over one or more selected planning groups that is solved as one combined joint-space problem with one synchronized result. -_Avoid_: Batch planning, independent planning - -**Planning Group ID**: -An API-level identifier for a planning group, always written as `{robot_name}/{group_name}`. `/` is reserved as the delimiter and is not part of either component. -_Avoid_: Bare group name, robot ID - -**Default Planning Group**: -The generated fallback planning group used by robot-scoped compatibility APIs when no planning group is specified explicitly. It is not inferred from arbitrary SRDF group uniqueness. -_Avoid_: Unique planning group, primary planning group - -**Robot-Scoped Compatibility API**: -A convenience API that accepts a robot name for common single-robot calls but immediately delegates to planning-group APIs through the robot's default planning group. It does not define a separate planning model, storage model, or groupless execution path. -_Avoid_: Robot-scoped planner, groupless API - -**Joint State**: -A joint-name-keyed robot state that can represent any set of joints and is not inherently coupled to a robot, planning group, planning group selection, or joint-name scope. At flat multi-robot or coordinator boundaries, joint names are required and are global joint names. Robot identity and local-vs-global meaning are provided by the API boundary or containing type, not by extra fields on the generic joint state. -_Avoid_: Planning-group-scoped state - -**Collision Target Joint State**: -A partial joint state used as input to planning-world collision checking. Its joint names are required and must be global joint names. Unmentioned joints are filled from the current planning-world state before collision is checked on the resulting full world configuration. It has no planning-group semantics. -_Avoid_: Full state, group-scoped collision state - -**Current Planning-World Joint State**: -A flat snapshot of fresh monitored controllable joint positions for all robots in the planning world. Joint names are global joint names. It is the source used to fill unmentioned joints when applying a collision target joint state; fallback/default backend states are not current planning-world joint state. -_Avoid_: Internal state, selected-group state, default model state - -**Robot Model Joint Names**: -The ordered controllable joints of a robot model in the model's local namespace. This usually aligns with the model's actuated joints, but is not itself a planning group. -_Avoid_: Implicit planning group - -**Local Model Joint Name**: -A joint name as it appears inside a robot model or SRDF before the model is bound to a concrete robot in a planning world. -_Avoid_: Runtime joint name, coordinator joint name - -**Robot-Scoped Joint State**: -A single-robot joint state whose robot identity is explicit outside the generic joint state. Robot-scoped APIs may accept unnamed ordered joint vectors in robot model joint order; when joint names are present, they are local model joint names because the robot identity is already explicit. -_Avoid_: Namespaced local joint state, prefixed joint state - -**Generated Plan**: -A flat planning result that may contain one or more robots. Joint states in a generated plan require names and use global joint names so the plan remains unambiguous across robot boundaries. -_Avoid_: Robot-scoped joint plan, local joint plan - -**Group-Scoped Preview**: -A visualization request for a generated plan over a planning group or planning group selection. A visualization backend may render the whole robot when partial-group rendering is not practical, but the API scope remains the generated plan's selected planning groups. -_Avoid_: Robot-scoped preview API - -**Global Joint Name**: -A boundary-level joint name that mechanically combines a robot name and local model joint name as `{robot_name}/{local_joint_name}` so it is stable and unique in flat joint-state representations, even when the local model joint name is already descriptive. `/` is reserved as the delimiter and is not part of either component. -_Avoid_: Resolved joint name, coordinator joint name, bare joint name, local joint name - -**Robot Placement**: -The placement of a robot model within the planning world. Robot placement belongs in the robot model description rather than in a separate planning configuration transform. -_Avoid_: Planning base pose, config placement transform diff --git a/openspec/changes/add-planning-groups/.openspec.yaml b/openspec/changes/add-planning-groups/.openspec.yaml deleted file mode 100644 index 494ca38369..0000000000 --- a/openspec/changes/add-planning-groups/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: dimos-capability -created: 2026-06-13 diff --git a/openspec/changes/add-planning-groups/design.md b/openspec/changes/add-planning-groups/design.md deleted file mode 100644 index cb1fa7e8fb..0000000000 --- a/openspec/changes/add-planning-groups/design.md +++ /dev/null @@ -1,555 +0,0 @@ -# Planning Groups Design - -## 1. Summary - -This change makes **Planning Group** a first-class manipulation planning concept in DimOS. - -Today, manipulation planning is centered on robot identity: planner and IK interfaces select a `WorldRobotID`, while `RobotModelConfig` carries one `joint_names`, one `base_link`, and one `end_effector_link`. That shape works for a single serial arm, but it conflates robot/model identity with the kinematic subset being planned. - -The new design separates those concerns: - -- Robot identity describes the hardware/model instance. -- Planning group identity describes the selectable kinematic planning unit. -- SRDF `` declarations are the primary source of planning groups. -- Existing single-chain robots without SRDF can continue through conservative fallback generation of `{robot_name}/manipulator`. -- Planning and IK APIs select planning groups, not robot IDs. -- Pose planning supports request-scoped auxiliary groups, such as torso/waist joints that contribute free DOFs without direct pose constraints. -- Generated plans store only selected group IDs and one synchronized resolved-joint path. -- Preview and execution project from that generated plan lazily. - -The design also standardizes public joint naming above model parsing: all public joint states and paths use resolved joint names of the form `{robot_name}/{local_joint_name}`. - -## 2. Motivation - -Current manipulation planning treats a robot instance as the planning unit. `PlannerSpec.plan_joint_path(...)`, `KinematicsSpec.solve(...)`, and several `WorldSpec` methods all center on `WorldRobotID`. `ManipulationModule` also stores planned paths and trajectories by `RobotName`. - -That makes several important cases awkward: - -- Arm-only planning versus torso+arm planning. -- Coordinated dual-arm planning. -- Future multi-robot coordinated planning. -- Robots with multiple selectable serial chains. -- Explicit distinction between controllable joints and planning groups. - -The current `RobotModelConfig` fields also hide planning-group semantics. A single `end_effector_link` and `base_link` imply one planning chain per robot config. A single `joint_names` list currently acts like a hidden planning group, while also serving as the controllable/coordinator joint set. - -Finally, local URDF joint names are ambiguous once multiple robots or arms with repeated names participate in one plan. Public state/path APIs need stable resolved names so a path can represent coordinated multi-group or multi-robot motion without relying on external robot scoping. - -## 3. Goals and Non-Goals - -### Goals - -- Make planning groups first-class planning units. -- Use SRDF `` declarations as the primary source of planning group definitions. -- Provide conservative fallback generation for current single-chain arms without SRDF. -- Require explicit planning group selection for planning and IK. -- Support coordinated planning over one or more selected planning groups. -- Support pose planning with request-scoped auxiliary groups that contribute free DOFs. -- Expose stable resolved joint names above model/SRDF parsing. -- Add group-aware IK and planner interfaces. -- Replace robot-scoped end-effector FK/Jacobian queries with group-scoped APIs. -- Store minimal generated plan artifacts and project lazily for preview/execution. -- Keep existing trajectory controllers unaware of planning groups. - -### Non-Goals - -- Full MoveIt SRDF support. -- First-class composite or nested planning groups. -- Mixed pose+joint target planning in one request. -- Atomic multi-task trajectory batch dispatch. -- Rollback-on-rejection for multi-task trajectory dispatch. -- Planning config placement transforms such as `base_pose`. -- Silent compatibility mode that treats old `joint_names` as an implicit planning group without validation. -- Making controllers, coordinator tasks, or hardware drivers planning-group-aware. - -## 4. Domain Model - -Root `CONTEXT.md` contains the canonical glossary for this change. The core design shape is: - -```text -PlanningGroupDefinition - model-level declaration from SRDF or fallback generation - ↓ bind to concrete robot/world -ResolvedPlanningGroup - runtime/world-bound group with resolved joints and link frames - ↓ selected by request -PlanningGroupSelection - one or more non-overlapping resolved groups - ↓ IK/planner solve -GeneratedPlan - selected group IDs + synchronized resolved-joint path -``` - -Important terms: - -- **Planning Group**: named selectable serial kinematic chain of robot joints used as the manipulation planning unit. -- **Planning Group Definition**: model-level declaration before binding to a concrete robot/world. -- **Resolved Planning Group**: runtime/world-bound group with concrete robot identity, namespace, resolved joints, and frame data. -- **Planning Group Selection**: one or more planning groups chosen for a planning request. -- **Auxiliary Planning Group**: group selected in a specific pose-planning request without receiving a direct pose constraint in that request. -- **Planning Group ID**: public identifier, always `{robot_name}/{group_name}`. -- **Planning Group Descriptor**: immutable query snapshot describing an available planning group; not a live handle. -- **Local Model Joint Name**: name inside URDF/SRDF, such as `joint1`. -- **Resolved Joint Name**: public world-level name, always `{robot_name}/{local_joint_name}`. - -Identifier layering: - -```text -robot_name Stable robot/model instance name. -WorldRobotID Internal runtime world handle only. -PlanningGroupID {robot_name}/{group_name} -Local joint name joint1 -Resolved joint name {robot_name}/{local_joint_name} -Coordinator name Hardware/control boundary name; default identity with resolved name. -``` - -`WorldRobotID` must not appear in public state, path, generated plan, or planning group selection APIs. - -## 5. Planning Group Sources - -Planning group definitions are discovered in this precedence order: - -1. Explicit `srdf_path` on robot/model config. -2. Conservative SRDF auto-discovery with visible warning. -3. Fallback single-chain generation. -4. Error if no supported SRDF groups exist and fallback cannot infer exactly one unambiguous serial chain. - -Supported SRDF forms for this change: - -```xml - - - -``` - -```xml - - - - - -``` - -Unsupported forms are skipped with warnings: - -- `` groups. -- Nested `` references. -- Mixed link/joint/chain forms. -- Branching, disconnected, unordered, or otherwise non-serial groups. -- SRDF `` metadata. - -If a caller later selects a skipped group, resolution fails as unknown or unsupported. - -## 6. Fallback Group Generation - -When no SRDF is available, DimOS may generate exactly one planning group: - -```text -group name: manipulator -group ID: {robot_name}/manipulator -``` - -Fallback rules: - -- Use `RobotModelConfig.joint_names` as the candidate controllable set. -- Validate that candidate joints form one unambiguous serial chain in the parsed model. -- Allow prismatic joints inside the serial chain. -- Exclude only terminal/tip prismatic joints when they are likely finger/gripper joints. -- Set fallback pose target tip to the last controlled chain link. -- Error for ambiguous, branching, disconnected, or multi-chain models; such robots require SRDF. - -This preserves current single serial arm behavior without pretending every robot's `joint_names` list is a valid planning group. - -## 7. End-Effector Semantics - -A planning group is defined by chain/joints, not by SRDF `` metadata. This change ignores SRDF `` entirely. - -Rules: - -- Chain-defined group: pose target frame is the chain `tip_link`. -- Explicit joint-list group: may have a pose target frame only if validation proves it is one serial chain with a unique tip. -- Group with no valid tip: may participate in joint planning or as an auxiliary group, but cannot be directly pose-targeted. - -Pose-targeted APIs validate only targeted groups. Auxiliary groups do not need to be pose-ineligible; auxiliary status is request-scoped. A group may have a tip and still be auxiliary in a particular request. - -## 8. Public Planning APIs - -Representative API shape for pose targets: - -```python -plan_to_poses( - pose_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, PoseStamped], - *, - auxiliary_groups: Sequence[PlanningGroupID | PlanningGroupDescriptor] = (), -) -> GeneratedPlan -``` - -Meaning: - -- `pose_targets` are selected groups with end-effector pose constraints in this request. -- `auxiliary_groups` are selected groups with no pose constraint in this request. -- Effective selection is `pose_targets.keys() ∪ auxiliary_groups`. -- Auxiliary groups are free DOFs for IK and planning. -- Mixed pose+joint targets are not supported in this change. - -Example: - -```python -plan_to_poses( - pose_targets={"robot/arm": target_pose}, - auxiliary_groups=["robot/torso"], -) -``` - -This plans one coordinated problem over arm and torso joints. The arm tip must reach `target_pose`; torso joints are free to move as needed. - -Representative API shape for joint targets: - -```python -plan_to_joint_targets( - joint_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, JointState], -) -> GeneratedPlan -``` - -Rules: - -- Joint targets are keyed by planning group at the API boundary. -- Each group's joint target keys must exactly match that group's selected resolved joints. -- Internally the request lowers to one ordered combined resolved-joint start/goal problem. - -Planning APIs may accept either a Planning Group ID string or a Planning Group Descriptor. Descriptors are ergonomic immutable snapshots. APIs normalize descriptors by extracting their ID and re-resolving current runtime group data. - -## 9. Spec Interface Changes - -This section refers to DimOS Python `Spec` Protocols, not OpenSpec behavior specs. - -### WorldSpec - -`WorldSpec` owns planning group listing and resolution: - -```python -list_planning_groups() -> Sequence[PlanningGroupDescriptor] -resolve_planning_groups(group_ids: Sequence[PlanningGroupID]) -> Sequence[ResolvedPlanningGroup] -``` - -Resolution responsibilities: - -- Validate IDs are known. -- Bind model-level definitions to concrete robot/world data. -- Convert local model joint names to resolved joint names. -- Detect overlapping resolved joints across selected groups and fail. -- Return enough resolved data for IK/planner/backend internals to map back to local names and model instances. - -Group-scoped query APIs replace robot-scoped end-effector APIs: - -```python -get_group_pose(ctx, group_id) -> PoseStamped -get_group_jacobian(ctx, group_id) -> ... -``` - -These are valid only for groups with a pose target frame. `get_link_pose(ctx, robot_id, link_name)` remains as a lower-level query. - -### KinematicsSpec - -IK must solve over the full effective planning group selection: - -```python -solve_pose_targets( - world: WorldSpec, - pose_targets: Mapping[PlanningGroupID | PlanningGroupDescriptor, PoseStamped], - *, - auxiliary_groups: Sequence[PlanningGroupID | PlanningGroupDescriptor] = (), - seed: JointState | None = None, - tolerances: PoseTolerance | None = None, - check_collision: bool = True, - max_attempts: int = 1, -) -> IKResult -``` - -IK constraints apply only to `pose_targets`. Auxiliary group joints are decision variables with no direct pose constraints. `IKResult.solution` contains exactly the selected resolved joints, not full robot/world state. - -### PlannerSpec - -Planner APIs operate over selected groups / resolved selected joints rather than a single `robot_id`. - -For joint planning: - -- Start and goal `JointState` keys must exactly equal selected resolved joints. -- Missing keys, extra keys, or partial goals fail early. - -For pose planning: - -- IK returns a selected-joint goal. -- The planner solves one combined joint-space problem from selected-joint start to selected-joint goal. - -Backends may return or raise `UNSUPPORTED` for backend limitations, including cross-robot coordinated planning. The public interface permits multi-robot selections. - -## 10. State, Naming, and Exactness Rules - -Above the model/SRDF parsing layer, all joint states use resolved joint names: - -```text -{robot_name}/{local_joint_name} -``` - -Examples: - -```text -left/joint1 -right/joint1 -a750/joint3 -``` - -Local names remain inside URDF/SRDF parsing and backend internals. Backends may strip the robot namespace and use `(local_joint_name, model_instance)` internally. - -Exactness rules: - -- Joint-space start keys must exactly equal selected resolved joints. -- Joint-space goal keys must exactly equal selected resolved joints. -- No missing selected joints. -- No extra joints. -- No partial targets. -- `IKResult.solution.keys()` equals selected resolved joints. -- `PlanningResult.path[i].keys()` equals selected resolved joints for every waypoint. - -These rules prevent silently planning for the wrong group or ignoring caller-supplied joints. - -## 11. Generated Plan Model - -`GeneratedPlan` is the canonical planning artifact: - -```python -GeneratedPlan: - group_ids: tuple[PlanningGroupID, ...] - path: list[JointState] - status: PlanningStatus - planning_time: float | None - path_length: float | None - iterations: int | None - message: str -``` - -`GeneratedPlan.path[i]` contains exactly the selected resolved joints for every waypoint. - -The plan does not store: - -- Per-robot paths. -- Per-task trajectories. -- Preview samples. -- Live world/planner handles. -- Controller-specific execution plans. - -`ManipulationModule` may keep `_last_plan: GeneratedPlan | None` as convenience state, but the returned plan object is canonical. - -## 12. Preview and Execution Projection - -Preview and execution project lazily from `GeneratedPlan`. - -### Preview - -`preview_plan(plan)`: - -- Uses the selected resolved-joint path. -- Projects into visualization/world monitor data as needed. -- Does not require execution-specific trajectory precomputation. - -### Execution - -`execute_plan(plan)`: - -1. Group resolved joint names by robot namespace/coordinator task. -2. Project the combined path into one `JointTrajectory` per affected trajectory task. -3. Order trajectory positions according to the task's configured joint order. -4. Convert resolved joint names to coordinator joint names if a boundary mapping exists. -5. Invoke each trajectory task. - -The current coordinator/JTC architecture already treats task invocation as asynchronous dispatch. JTCs run concurrently in the coordinator tick loop. This change does not add atomic batch dispatch, rollback-on-rejection, or a new execution batch abstraction. - -Planning groups do not enter the controller layer. Controllers consume joint-name-keyed trajectories. - -## 13. Migration Plan - -Configuration changes: - -- Add `srdf_path` to `RobotConfig`. -- Add `srdf_path` to `RobotModelConfig`. -- Store parsed planning group definitions on `RobotModelConfig`. -- Keep `RobotConfig.joint_names` and `RobotModelConfig.joint_names`, but define them as the controllable/coordinator joint set, not a planning group. - -Fields removed or deprecated under the new design: - -- `RobotConfig.base_link` -- `RobotConfig.base_pose` -- `RobotModelConfig.base_link` -- `RobotModelConfig.base_pose` -- `RobotModelConfig.end_effector_link` - -Implementation rollout: - -1. Add planning group data model and SRDF/fallback extraction. -2. Add deterministic local/resolved joint-name helpers. -3. Update `WorldSpec` and Drake world to list and resolve planning groups. -4. Add group-scoped pose/Jacobian APIs. -5. Update `KinematicsSpec` and IK implementation to solve selected pose targets plus auxiliary free groups. -6. Update `PlannerSpec` and planner implementation to operate over selected resolved joints. -7. Replace `ManipulationModule` robot-keyed planned path/trajectory caches with `GeneratedPlan` and optional `_last_plan`. -8. Update preview and execution projection. -9. Update manipulation skills/wrappers and documentation. -10. Update existing robot configs; rely on fallback for current single serial arms unless SRDF is added. - -Generated registry updates are not expected unless implementation changes blueprint names or adds/removes blueprint module-level variables. If that happens, run: - -```bash -pytest dimos/robot/test_all_blueprints_generation.py -``` - -## 14. Validation and Errors - -Validation should fail clearly for: - -- Unknown planning group ID. -- Unsupported selected SRDF group form. -- Fallback cannot infer one serial chain. -- Selected planning groups overlap in resolved joints. -- Pose-targeted group has no valid tip/pose target frame. -- Joint target keys do not exactly match selected group joints. -- Start/goal joint states contain missing or extra selected joints. -- Backend does not support the requested coordinated planning problem. -- Cross-robot coordinated planning requested on a backend that cannot support it. - -Warnings should be emitted for: - -- SRDF auto-discovery. -- Unsupported SRDF groups skipped during parsing. - -Manual QA should cover: - -- Single-arm no-SRDF fallback planning. -- SRDF chain group planning. -- SRDF joint-list group planning. -- Arm+torso pose planning where torso is auxiliary/free. -- Multi-group result shape and no-overlap validation. -- Multi-task execution projection with distinct joint namespaces. - -## 15. Alternatives Considered - -- **Composite groups as first-class objects**: rejected. Selecting multiple groups per request is enough and avoids duplicate group modeling. -- **Bare group names**: rejected. Planning Group IDs are always namespaced as `{robot_name}/{group_name}`. -- **Robot-scoped planning API**: rejected. Robot identity and planning group selection are separate concerns. -- **Full SRDF support**: deferred. This change supports only serial chain and ordered joint-list groups. -- **Parsing SRDF ``**: rejected for this change. Group pose target frame comes from chain/joint validation, not end-effector metadata. -- **Implicit default planning group**: rejected. Planning group selection is required. -- **Treating old `joint_names` as a compatibility planning group**: rejected unless it validates through conservative fallback. -- **`include_groups` with implicit semantics**: rejected in favor of request-scoped `auxiliary_groups`. -- **Enforcing auxiliary groups to be joint-only/no-EEF**: rejected. Auxiliary status belongs to the request, not the group definition. -- **Mixed pose+joint constraints now**: deferred. -- **Precomputed execution plans**: rejected. Generated plans stay minimal; downstream projections happen lazily. -- **Atomic multi-task trajectory dispatch**: deferred. Existing task invocation is acceptable for now. - -## 16. Rollout / Implementation Phases - -Suggested implementation phases: - -1. **Data model and parsing** - - Add planning group definition/descriptor/resolved-group types. - - Add SRDF path fields. - - Implement supported SRDF group parsing. - - Implement fallback generation. - -2. **World resolution** - - Add `WorldSpec.list_planning_groups()` and `resolve_planning_groups(...)`. - - Update Drake world to bind group definitions to model instances and resolved names. - - Add overlap validation. - -3. **Group-scoped kinematics queries** - - Add `get_group_pose(...)` and `get_group_jacobian(...)`. - - Remove/deprecate robot-scoped end-effector queries. - -4. **IK and planner interfaces** - - Update IK to accept pose targets plus auxiliary groups. - - Update planner to operate over selected resolved joints. - - Enforce exact start/goal key rules. - -5. **Generated plan flow** - - Add `GeneratedPlan`. - - Replace robot-keyed planned path/trajectory caches. - - Store only optional `_last_plan` convenience state. - -6. **Preview and execution projection** - - Project generated plans to visualization as needed. - - Project generated plans to per-task `JointTrajectory` at execution time. - -7. **Robot config migration and docs** - - Update existing robot configs. - - Add docs for SRDF support, fallback generation, APIs, and naming rules. - - Update manipulation skills/wrappers. - -## 17. Safety / Simulation / Replay - -This is primarily a planning API and modeling change. It should not change low-level trajectory controller semantics. - -Hardware-facing assumptions: - -- Execution still sends `JointTrajectory` messages to existing coordinator trajectory tasks. -- Affected trajectory tasks control disjoint resolved/coordinator joint sets. -- Multi-task dispatch is fast and non-blocking. -- Trajectory tasks execute concurrently in the coordinator tick loop. -- No new hardware-safety behavior, rollback, or atomic all-or-nothing dispatch is introduced. - -Simulation and replay should mirror hardware behavior because group resolution and generated plan projection happen above hardware drivers. Existing single-arm simulation/replay stacks should continue through fallback if they form one unambiguous serial chain. - -## 18. Risks / Trade-offs - -- **API breakage:** Existing callers plan by robot name or robot ID. Mitigation: provide migration docs and temporary wrapper conveniences where appropriate. -- **Partial SRDF support confusion:** Users may expect full MoveIt SRDF semantics. Mitigation: warn on skipped groups and document supported forms clearly. -- **Fallback misclassification:** Terminal prismatic stripping may accidentally exclude a real controllable prismatic axis. Mitigation: fallback only applies to unambiguous single chains; SRDF is required for precise modeling. -- **Joint naming migration cost:** Resolved names require updates across planner results, state monitors, and execution projection. Mitigation: deterministic helper conversion and strict layering. -- **Backend capability mismatch:** Some planners may not support multi-robot coordinated planning. Mitigation: allow `UNSUPPORTED` while preserving interface semantics. -- **Dispatch skew for multiple trajectory tasks:** Sequential task invocation can create tiny start-time differences. Mitigation: accepted for now; future coordinator batch dispatch can address this if needed. - -## 19. Open Questions - -- Exact error/status enum names for unsupported groups, no target frame, overlapping groups, and backend-unsupported problems. -- Exact temporary compatibility wrappers, if any, for existing manipulation skill APIs. -- Whether to write a short ADR for removing planning config placement fields in favor of URDF/model placement. -- Whether future coordinator-level `execute_batch(...)` is needed for tighter multi-task synchronization. - -## Appendix: Design Q&A Summary - -- **Term:** Use Planning Group; avoid Move Group/movegroup. -- **Ownership:** Define groups at model/config level; resolve to runtime/world-bound groups. -- **Composites:** Do not create composite groups now; select multiple groups per request. -- **Multi-group meaning:** One coordinated joint-space problem and one synchronized result. -- **End effectors:** Groups are defined by chain/joints, not SRDF `` metadata. -- **Group declaration:** Support chain or ordered joint list; validate as serial chain. -- **Default selection:** Planning group selection is required; no implicit default selection. -- **IDs:** Planning Group ID is always `{robot_name}/{group_name}`. -- **Descriptors:** Query APIs return immutable snapshots, not live handles. -- **Selectors:** APIs may accept group ID strings or descriptors; no dedicated selector type. -- **Joint goals:** Joint target APIs are keyed by planning group at API boundary, then lowered to one ordered resolved-joint problem. -- **Resolution:** `WorldSpec` owns group listing/resolution; planner delegates resolution to world. -- **Joint state exactness:** Joint-space start/goal keys must exactly equal selected resolved joints. -- **Source of truth:** SRDF first, conservative fallback only when no SRDF is present. -- **Fallback name:** Generated group is `manipulator`. -- **Fallback source:** Use `RobotModelConfig.joint_names` as candidate controllable joints. -- **Prismatic joints:** Middle prismatic joints are allowed; terminal/tip prismatic finger joints may be excluded. -- **SRDF scope:** Parse `` only; ignore ``. -- **Unsupported SRDF:** Skip unsupported group forms with warnings. -- **SRDF discovery:** Explicit path, then warning auto-discovery, then fallback, then error. -- **Config fields:** Add `srdf_path`; keep `base_link`, `end_effector_link`, and `base_pose` as explicit compatibility fields while new planning code uses planning-group base/tip links and model-owned placement. -- **Robot placement:** Placement belongs in URDF/model, not separate planning config transforms. -- **FK/Jacobian:** Replace robot-scoped end-effector APIs with group-scoped APIs. -- **Cross-robot planning:** Interface allows it; backends may report unsupported. -- **Overlaps:** Selected groups must never overlap in resolved joints. -- **Result shape:** `PlanningResult.path` remains combined and synchronized. -- **Stored plan:** Store selected group IDs and path only; project lazily for preview/execution. -- **Resolved naming:** Above parsing, use `{robot_name}/{local_joint_name}` everywhere. -- **Coordinator mapping:** Coordinator names are a control-boundary concern; default identity with resolved names. -- **Auxiliary groups:** Auxiliary means selected without pose constraint in this request only. -- **Auxiliary DOFs:** Auxiliary groups are free DOFs for pose planning. -- **Mixed targets:** No mixed pose+joint target API in this change. -- **IK:** IK solves over pose-targeted groups plus auxiliary groups. -- **IK result:** `IKResult.solution` contains exactly selected resolved joints. -- **Planning result:** `PlanningResult.path` contains exactly selected resolved joints. -- **Execution:** Split path by trajectory task and send to existing JTCs; no new batch/rollback semantics. -- **Generated plan:** Returned plan object is canonical; module last-plan storage is convenience. diff --git a/openspec/changes/add-planning-groups/docs.md b/openspec/changes/add-planning-groups/docs.md deleted file mode 100644 index 1a8ad2b45b..0000000000 --- a/openspec/changes/add-planning-groups/docs.md +++ /dev/null @@ -1,45 +0,0 @@ -## User-Facing Docs - -- Update `docs/usage/` manipulation planning documentation to introduce planning groups as the user-facing planning selection unit. -- Document Planning Group IDs as `{robot_name}/{group_name}` and resolved joint names as `{robot_name}/{local_joint_name}`. -- Document supported SRDF forms: - - `` - - `...` when the joints validate as one serial chain. -- Document unsupported SRDF forms and warning behavior for skipped groups. -- Document fallback behavior for robots without SRDF: one generated `{robot_name}/manipulator` group only when configured controllable joints form one unambiguous serial chain. -- Document public planning API usage: - - pose targets keyed by planning group - - request-scoped `auxiliary_groups` - - joint targets keyed by planning group - - generated plans returned as the canonical artifact -- Document lazy preview/execution flow: generated plan first, then `preview_plan(plan)` and `execute_plan(plan)` project as needed. - -## Contributor Docs - -- Update `docs/development/` manipulation/planning contributor documentation, if present, to explain: - - SRDF/fallback extraction responsibilities - - local versus resolved joint-name layering - - where group resolution belongs - - why controllers should remain planning-group agnostic -- If no dedicated manipulation contributor doc exists, add the contributor notes to the user-facing manipulation planning doc or create a short development note for planning backends. - -## Coding-Agent Docs - -- Update `docs/coding-agents/` or `AGENTS.md` only if implementation introduces new recurring coding-agent guidance. -- Likely guidance: - - do not add robot-scoped planning APIs for new manipulation work - - use explicit Planning Group IDs in examples/tests - - keep local URDF/SRDF names below parsing/backend internals - - use resolved joint names in public paths/states - -## Doc Validation - -- Run doc link validation if available: - - `uv run doclinks` -- For docs containing executable Python snippets, run the relevant markdown execution command if supported: - - `uv run md-babel-py run ` -- Run relevant tests that exercise documented examples or API snippets after implementation. - -## No Docs Needed - -Documentation is needed. This change alters public manipulation planning concepts, API examples, naming conventions, SRDF support expectations, and migration guidance for existing callers. diff --git a/openspec/changes/add-planning-groups/proposal.md b/openspec/changes/add-planning-groups/proposal.md deleted file mode 100644 index ce14a0ee70..0000000000 --- a/openspec/changes/add-planning-groups/proposal.md +++ /dev/null @@ -1,53 +0,0 @@ -## Why - -DimOS manipulation planning is currently robot-centric: planner and kinematics interfaces select a `robot_id`, while `RobotModelConfig` carries a single `joint_names`, `base_link`, and `end_effector_link` shape. That works for a single serial arm, but it hides the actual planning unit and makes torso+arm, dual-arm, and coordinated multi-group planning awkward or ambiguous. - -Planning groups should be first-class. Robot identity should describe the hardware/model instance, while planning group selection should describe which kinematic chains participate in IK and motion planning. This change also makes joint naming unambiguous above the model parsing layer by using stable resolved joint names. - -## What Changes - -- Add first-class planning group definitions sourced primarily from SRDF `` entries. -- Add conservative fallback generation of one `manipulator` planning group for unambiguous single-chain robots without SRDF. -- **BREAKING**: Planning and IK APIs move from robot-ID selection to explicit planning group selection. -- **BREAKING**: Joint states and generated paths above model parsing use resolved joint names of the form `{robot_name}/{local_joint_name}`. -- Add pose planning over pose-targeted planning groups plus request-scoped auxiliary planning groups that contribute free DOFs. -- Add coordinated multi-group planning semantics: one selected joint set, one synchronized path, and no overlapping selected joints. -- Replace robot-scoped end-effector FK/Jacobian queries with group-scoped queries. -- Introduce a minimal generated plan artifact that stores selected group IDs and a combined resolved-joint path, projecting lazily for preview and execution. -- Remove planning configuration concepts that duplicate robot model structure, including robot-level planning `base_link`, `end_effector_link`, and `base_pose` fields in the new design. - -## Affected DimOS Surfaces - -- Modules/streams: - - Manipulation planning module plan/preview/execute flow. - - Planning `WorldSpec`, `KinematicsSpec`, and `PlannerSpec` interfaces. - - Drake planning world implementation and world monitor/preview integration. - - Robot model/config parsing and model-to-planning config conversion. -- Blueprints/CLI: - - Existing manipulation blueprints should continue for single serial arms through fallback group generation. - - Ambiguous, branching, or multi-arm robots require SRDF rather than silent compatibility behavior. -- Skills/MCP: - - Manipulation skills that call pose or joint planning must select planning groups explicitly or use wrapper defaults supplied by the skill layer. -- Hardware/simulation/replay: - - Execution still sends projected trajectories to existing trajectory controller tasks. - - Multi-task execution dispatches per-task trajectories; trajectory controllers own runtime concurrency. - - No new hardware-safety behavior or atomic multi-task batch dispatch is introduced. -- Docs/generated registries: - - User/developer docs for manipulation planning APIs, SRDF support, fallback generation, and joint naming need updates. - - No generated blueprint registry changes are expected unless robot config/blueprint names change during implementation. - -## Capabilities - -### New Capabilities - -- `manipulation-planning-groups`: Planning group discovery, selection, coordinated planning, IK target semantics, generated plans, and preview/execution projection. - -### Modified Capabilities - -- None. No existing OpenSpec capability specs are present in this repository checkout. - -## Impact - -This is a public manipulation planning API redesign. Existing code that plans by robot name or assumes bare local joint names will need migration to explicit planning group IDs and resolved joint names. Existing single-arm robots without SRDF should keep working through generated `{robot_name}/manipulator` groups if their configured controllable joints form one unambiguous serial chain. - -The implementation needs tests for SRDF parsing, fallback generation, group resolution, resolved joint naming, IK with auxiliary groups, exact joint-target validation, multi-group planning result shape, and lazy preview/execution projection. Documentation should emphasize the distinction between robot identity, planning group identity, local model joint names, resolved joint names, and coordinator joint names. diff --git a/openspec/changes/add-planning-groups/specs/manipulation-planning-groups/spec.md b/openspec/changes/add-planning-groups/specs/manipulation-planning-groups/spec.md deleted file mode 100644 index 603a392b0e..0000000000 --- a/openspec/changes/add-planning-groups/specs/manipulation-planning-groups/spec.md +++ /dev/null @@ -1,216 +0,0 @@ -## ADDED Requirements - -### Requirement: Planning group discovery - -DimOS SHALL expose manipulation planning groups discovered from supported SRDF group declarations or from conservative fallback generation when no SRDF is available. - -#### Scenario: supported SRDF chain group is discovered -- **GIVEN** a robot model configuration references an SRDF containing a `` with one `` -- **WHEN** planning groups are listed for that robot -- **THEN** the chain group is available as a planning group -- **AND** its public Planning Group ID is `{robot_name}/{group_name}` - -#### Scenario: supported SRDF joint-list group is discovered -- **GIVEN** a robot model configuration references an SRDF containing a `` with an ordered list of `` entries -- **AND** those joints validate as one serial chain -- **WHEN** planning groups are listed for that robot -- **THEN** the joint-list group is available as a planning group - -#### Scenario: unsupported SRDF groups are skipped -- **GIVEN** an SRDF contains unsupported group forms such as link groups, nested group references, mixed forms, or non-serial groups -- **WHEN** planning groups are discovered -- **THEN** unsupported groups are skipped with warnings -- **AND** supported groups from the same SRDF remain available - -#### Scenario: fallback group is generated for unambiguous single-chain robot -- **GIVEN** a robot has no SRDF -- **AND** its configured controllable joints form exactly one unambiguous serial chain -- **WHEN** planning groups are listed for that robot -- **THEN** DimOS exposes one generated planning group named `manipulator` -- **AND** the group ID is `{robot_name}/manipulator` - -#### Scenario: ambiguous fallback fails -- **GIVEN** a robot has no SRDF -- **AND** its configured controllable joints are branching, disconnected, ambiguous, or otherwise not one serial chain -- **WHEN** planning groups are discovered -- **THEN** DimOS fails with an error requiring SRDF rather than silently creating an implicit group - -### Requirement: Planning group descriptors - -DimOS SHALL provide read-only planning group descriptors that identify available groups without acting as live runtime handles. - -#### Scenario: descriptor includes stable identity -- **GIVEN** a planning group exists for a robot -- **WHEN** a caller lists planning groups -- **THEN** the returned descriptor includes the Planning Group ID -- **AND** the Planning Group ID is stable and namespaced as `{robot_name}/{group_name}` - -#### Scenario: descriptor can be used as selector -- **GIVEN** a caller receives a planning group descriptor from a query API -- **WHEN** the caller passes that descriptor to a planning API -- **THEN** DimOS selects the group by descriptor ID -- **AND** DimOS re-resolves current runtime group data instead of trusting stale descriptor fields - -### Requirement: Resolved joint naming - -DimOS SHALL expose resolved joint names above the model parsing layer using `{robot_name}/{local_joint_name}`. - -#### Scenario: generated path uses resolved names -- **GIVEN** a robot named `left` has a local model joint named `joint1` -- **WHEN** DimOS returns a planning result path that includes that joint -- **THEN** the path uses `left/joint1` as the joint name -- **AND** the bare local name `joint1` is not exposed in the public path - -#### Scenario: duplicate local names remain unambiguous -- **GIVEN** two robots both contain a local model joint named `joint1` -- **WHEN** a coordinated plan includes both joints -- **THEN** the plan distinguishes them with resolved names such as `left/joint1` and `right/joint1` - -### Requirement: Planning group selection validation - -DimOS SHALL validate that selected planning groups are known and do not overlap in resolved joints. - -#### Scenario: non-overlapping groups are accepted -- **GIVEN** a planning request selects two known planning groups with disjoint resolved joints -- **WHEN** DimOS resolves the planning group selection -- **THEN** the selection is accepted -- **AND** the effective selected joint set is the union of both groups' resolved joints - -#### Scenario: overlapping groups are rejected -- **GIVEN** a planning request selects two planning groups that share at least one resolved joint -- **WHEN** DimOS resolves the planning group selection -- **THEN** the request fails before planning begins -- **AND** the error identifies overlapping selected joints or groups - -#### Scenario: unknown group is rejected -- **GIVEN** a planning request references a Planning Group ID that is not available -- **WHEN** DimOS resolves the planning group selection -- **THEN** the request fails with an unknown planning group error - -### Requirement: Pose planning with auxiliary groups - -DimOS SHALL support pose planning with pose-targeted groups and request-scoped auxiliary groups that contribute free degrees of freedom. - -#### Scenario: auxiliary torso helps arm pose planning -- **GIVEN** a pose planning request targets `robot/arm` -- **AND** the request includes `robot/torso` as an auxiliary group -- **WHEN** DimOS solves IK and planning for the request -- **THEN** the effective planning selection includes both arm and torso joints -- **AND** the arm pose target is enforced -- **AND** torso joints are free decision variables with no direct pose constraint in that request - -#### Scenario: auxiliary status is request-scoped -- **GIVEN** a planning group has a valid pose target frame -- **WHEN** that group is listed in `auxiliary_groups` for a pose planning request -- **THEN** DimOS treats it as unconstrained by pose for that request -- **AND** the same group may be directly pose-targeted in a different request - -#### Scenario: pose-targeted group without target frame is rejected -- **GIVEN** a planning group has no valid pose target frame -- **WHEN** a caller uses that group as a key in pose targets -- **THEN** DimOS rejects the request before planning begins - -### Requirement: Joint target planning exactness - -DimOS SHALL require joint target planning requests to provide exact selected resolved joint keys. - -#### Scenario: exact joint target is accepted -- **GIVEN** a joint target request selects a planning group with resolved joints `robot/joint1` and `robot/joint2` -- **WHEN** the request provides target values for exactly `robot/joint1` and `robot/joint2` -- **THEN** DimOS accepts the target for planning - -#### Scenario: missing joint target is rejected -- **GIVEN** a joint target request selects a planning group with resolved joints `robot/joint1` and `robot/joint2` -- **WHEN** the request provides a target for only `robot/joint1` -- **THEN** DimOS rejects the request as incomplete - -#### Scenario: extra joint target is rejected -- **GIVEN** a joint target request selects a planning group with resolved joints `robot/joint1` and `robot/joint2` -- **WHEN** the request also includes `robot/joint3` -- **THEN** DimOS rejects the request because the target contains joints outside the selected planning groups - -### Requirement: IK result shape - -DimOS SHALL return IK solutions containing exactly the resolved joints selected by the effective planning group selection. - -#### Scenario: IK result excludes unrelated joints -- **GIVEN** a pose request targets `robot/arm` and includes `robot/torso` as auxiliary -- **WHEN** IK succeeds -- **THEN** the IK solution contains arm and torso resolved joints -- **AND** it excludes unrelated gripper, base, or other-arm joints that were not selected - -#### Scenario: IK solves over auxiliary joints -- **GIVEN** a pose request has auxiliary groups -- **WHEN** IK attempts to satisfy pose targets -- **THEN** auxiliary group joints are available as free variables during the solve - -### Requirement: Generated plan artifact - -DimOS SHALL return a generated plan as the canonical artifact for successful planning. - -#### Scenario: generated plan contains selected groups and combined path -- **GIVEN** a planning request succeeds for one or more planning groups -- **WHEN** DimOS returns the generated plan -- **THEN** the plan includes the selected Planning Group IDs -- **AND** the plan includes one synchronized path of resolved-joint-keyed joint states - -#### Scenario: generated plan path contains exactly selected joints -- **GIVEN** a generated plan was produced for a planning group selection -- **WHEN** a caller inspects any waypoint in the path -- **THEN** that waypoint contains exactly the selected resolved joints -- **AND** unrelated joints are excluded - -#### Scenario: generated plan is reusable for downstream calls -- **GIVEN** a caller receives a generated plan -- **WHEN** the caller previews or executes the plan -- **THEN** DimOS uses the generated plan as the source artifact -- **AND** the caller does not need hidden robot-keyed planned path state - -### Requirement: Preview and execution projection - -DimOS SHALL project generated plans lazily for preview and execution without making controllers planning-group-aware. - -#### Scenario: preview projects selected path -- **GIVEN** a generated plan contains a resolved-joint path -- **WHEN** a caller previews the plan -- **THEN** DimOS projects the path into visualization using the selected joints -- **AND** preview does not require a precomputed execution trajectory stored in the plan - -#### Scenario: execution projects per trajectory task -- **GIVEN** a generated plan contains resolved joints for one or more coordinator trajectory tasks -- **WHEN** a caller executes the plan -- **THEN** DimOS projects the combined path into one trajectory per affected task -- **AND** each trajectory is ordered according to that task's configured joint order -- **AND** planning group concepts are not exposed to the trajectory controller - -#### Scenario: multi-task execution is dispatched without batch atomicity -- **GIVEN** a generated plan affects multiple trajectory tasks with disjoint joints -- **WHEN** the plan is executed -- **THEN** DimOS dispatches projected trajectories to the affected tasks -- **AND** runtime concurrency is handled by the coordinator and trajectory tasks -- **AND** DimOS does not require an atomic batch dispatch for this change - -### Requirement: Group-scoped kinematics queries - -DimOS SHALL provide group-scoped pose and Jacobian queries for planning groups with valid pose target frames. - -#### Scenario: group pose query succeeds for chain group -- **GIVEN** a planning group has a valid tip or pose target frame -- **WHEN** a caller requests the group's pose -- **THEN** DimOS returns the pose for that group target frame - -#### Scenario: group pose query fails without target frame -- **GIVEN** a planning group has no valid pose target frame -- **WHEN** a caller requests the group's pose or Jacobian -- **THEN** DimOS fails with a clear error rather than guessing a frame - -### Requirement: Backend unsupported reporting - -DimOS SHALL allow planning backends to reject coordinated planning problems they cannot support. - -#### Scenario: cross-robot planning unsupported by backend -- **GIVEN** a request selects planning groups across multiple robots -- **AND** the active backend cannot solve cross-robot coordinated planning -- **WHEN** planning is requested -- **THEN** DimOS reports the request as unsupported -- **AND** the failure occurs without sending commands to trajectory controllers diff --git a/openspec/changes/add-planning-groups/tasks.md b/openspec/changes/add-planning-groups/tasks.md deleted file mode 100644 index e8523fc4bd..0000000000 --- a/openspec/changes/add-planning-groups/tasks.md +++ /dev/null @@ -1,92 +0,0 @@ -## 1. Planning group data model and parsing - -- [x] 1.1 Add planning group model types for definitions, descriptors, resolved groups, and generated plans. -- [x] 1.2 Add `srdf_path` to robot/model configuration and carry it through model-to-planning config conversion. -- [x] 1.3 Implement SRDF parsing for supported `` declarations. -- [x] 1.4 Implement SRDF parsing for ordered `...` declarations that validate as one serial chain. -- [x] 1.5 Emit warnings and skip unsupported SRDF groups, including link groups, nested group references, mixed forms, and non-serial groups. -- [x] 1.6 Ignore SRDF `` metadata for planning group extraction. -- [x] 1.7 Implement conservative SRDF auto-discovery with visible warning after explicit `srdf_path` lookup fails or is absent. -- [x] 1.8 Implement fallback generation of `{robot_name}/manipulator` from `RobotModelConfig.joint_names` when no SRDF is available. -- [x] 1.9 Validate fallback as exactly one unambiguous serial chain, allowing middle prismatic joints and excluding only terminal/tip prismatic finger joints. -- [x] 1.10 Add unit tests for SRDF chain groups, joint-list groups, skipped unsupported groups, and fallback success/failure cases. - -## 2. Naming and group resolution - -- [x] 2.1 Add deterministic helpers for local model joint names to resolved joint names: `{robot_name}/{local_joint_name}`. -- [x] 2.2 Add inverse validation/stripping helper for backend internals that need local joint names. -- [x] 2.3 Update public joint-state/path surfaces above model parsing to use resolved joint names. -- [x] 2.4 Add `WorldSpec.list_planning_groups()` and return immutable planning group descriptor snapshots. -- [x] 2.5 Add `WorldSpec.resolve_planning_groups(...)` and bind definitions to concrete robot/world data. -- [x] 2.6 Validate unknown group IDs during resolution. -- [x] 2.7 Validate selected groups never overlap in resolved joints. -- [x] 2.8 Update Drake world internals to map resolved joint names to local joint names and model instances. -- [x] 2.9 Add focused tests for descriptors, stable IDs, resolved names, duplicate local joint disambiguation, unknown groups, and overlap rejection. - -## 3. Group-scoped world and kinematics APIs - -- [x] 3.1 Add group-scoped pose query API for planning groups with valid pose target frames. -- [x] 3.2 Add group-scoped Jacobian query API for planning groups with valid pose target frames. -- [x] 3.3 Preserve lower-level link pose querying for explicit robot/link lookups. -- [x] 3.4 Remove or deprecate robot-scoped end-effector FK/Jacobian APIs. -- [x] 3.5 Update `KinematicsSpec` to solve pose targets keyed by planning group plus request-scoped auxiliary groups. -- [x] 3.6 Ensure IK solves over the full effective selection and treats auxiliary group joints as free variables. -- [x] 3.7 Ensure `IKResult.solution` contains exactly selected resolved joints and excludes unrelated joints. -- [x] 3.8 Add tests for pose-targeted groups, auxiliary groups, no-target-frame rejection, and selected-joint-only IK results. - -## 4. Planner APIs and generated plan flow - -- [x] 4.1 Update planner APIs to accept planning group selection / resolved selected joints instead of a single `robot_id` planning target. -- [x] 4.2 Enforce exact start and goal `JointState` keys for joint-space planning: no missing, extra, or partial joints. -- [x] 4.3 Implement pose planning lowering from pose targets plus auxiliary groups to IK goal and combined joint-space planning. -- [x] 4.4 Allow backends to report `UNSUPPORTED` for coordinated planning problems they cannot solve. -- [x] 4.5 Add `GeneratedPlan` as the canonical returned planning artifact with selected group IDs and combined resolved-joint path. -- [x] 4.6 Ensure every `GeneratedPlan.path` waypoint contains exactly selected resolved joints. -- [x] 4.7 Replace robot-keyed planned path and planned trajectory caches in `ManipulationModule` with optional `_last_plan` convenience state. -- [x] 4.8 Add tests for joint target exactness, pose planning result shape, multi-group synchronized paths, backend unsupported reporting, and generated plan reuse. - -## 5. Preview and execution projection - -- [x] 5.1 Update preview APIs to accept an explicit `GeneratedPlan` and optionally fall back to `_last_plan` convenience state. -- [x] 5.2 Project generated plan paths lazily into visualization/world monitor calls. -- [x] 5.3 Update execution APIs to accept an explicit `GeneratedPlan` and optionally fall back to `_last_plan` convenience state. -- [x] 5.4 Project generated plan paths lazily into one `JointTrajectory` per affected coordinator trajectory task. -- [x] 5.5 Order projected trajectory positions according to each task's configured joint order. -- [x] 5.6 Convert resolved joint names to coordinator joint names at the execution boundary when a mapping exists. -- [x] 5.7 Keep trajectory controllers and coordinator tasks planning-group agnostic. -- [x] 5.8 Add tests for single-task execution projection, multi-task execution projection, task joint ordering, and preview projection. - -## 6. Robot config migration and API cleanup - -- [x] 6.1 Reframe planning-level `RobotConfig.base_link` and `RobotConfig.base_pose` usage as active compatibility behavior. -- [x] 6.2 Reframe planning-level `RobotModelConfig.base_link`, `RobotModelConfig.base_pose`, and `RobotModelConfig.end_effector_link` usage as active compatibility behavior. -- [x] 6.3 Keep and document `RobotConfig.joint_names` and `RobotModelConfig.joint_names` as controllable/coordinator joint sets, not planning groups. -- [x] 6.4 Update existing robot catalog/config entries to use SRDF where needed or rely on fallback for unambiguous single-chain arms. -- [x] 6.5 Update manipulation skills/wrappers to select planning groups explicitly or provide clear wrapper-level defaults. -- [x] 6.6 Run `pytest dimos/robot/test_all_blueprints_generation.py` if implementation changes blueprint names or generated registry inputs. - -## 7. Documentation - -- [x] 7.1 Update user-facing manipulation planning docs with planning group concepts, IDs, resolved joint names, and API examples. -- [x] 7.2 Document supported and unsupported SRDF forms plus skipped-group warning behavior. -- [x] 7.3 Document fallback generation rules and failure behavior for ambiguous robots. -- [x] 7.4 Document pose targets, auxiliary groups, joint targets, generated plans, preview, and execution flow. -- [x] 7.5 Update contributor docs or add a development note covering group resolution ownership, local/resolved naming, and controller boundaries. -- [x] 7.6 Update coding-agent docs or `AGENTS.md` only if implementation introduces recurring guidance beyond the existing design docs. - -## 8. Verification - -- [x] 8.1 Run `openspec validate add-planning-groups`. -- [x] 8.2 Run focused tests for robot config/model parsing changes. -- [x] 8.3 Run focused tests for planning group SRDF/fallback parsing. -- [x] 8.4 Run focused tests for `WorldSpec`/Drake world group resolution and group-scoped queries. -- [x] 8.5 Run focused tests for IK and planner selected-joint semantics. -- [x] 8.6 Run focused tests for `ManipulationModule` generated plan, preview, and execution projection. -- [x] 8.7 Run broader manipulation/planning pytest targets touched by the implementation. -- [x] 8.8 Run type checks for changed planning/manipulation modules if feasible: `uv run mypy dimos/` or a narrower supported target. Attempted narrow mypy; unavailable in this environment (`mypy` executable missing). -- [x] 8.9 Run docs validation commands for changed docs, including `uv run doclinks` if available. -- [x] 8.10 Run markdown snippet validation with `uv run md-babel-py run ` for docs containing executable Python examples, if available. -- [x] 8.11 Manually QA a single-arm no-SRDF fallback planning flow in replay or simulation. Covered by `dimos/e2e_tests/test_manipulation_planning_groups.py::test_single_arm_plans_and_executes_through_control_coordinator` using `openarm-mock-planner-coordinator` under `self_hosted_large`. -- [ ] 8.12 Manually QA an SRDF-backed chain group flow if a test fixture or robot config is available. -- [ ] 8.13 Manually QA arm-plus-auxiliary-group pose planning in simulation/replay if a suitable model is available. -- [x] 8.14 Manually QA a dual-arm planning flow by launching a dual-arm planning example, preferably OpenArm if available or another suitable dual-arm robot stack, then using the manipulation client to initiate one coordinated plan that selects both arms' planning groups and verifies the generated plan contains both arms' resolved joint names. Covered by `dimos/e2e_tests/test_manipulation_planning_groups.py::test_dual_arm_plans_and_dispatches_both_arms_through_control_coordinator` using `openarm-mock-planner-coordinator` under `self_hosted_large`. diff --git a/openspec/changes/add-viser-planning-target-set/.openspec.yaml b/openspec/changes/add-viser-planning-target-set/.openspec.yaml deleted file mode 100644 index d57ce332e0..0000000000 --- a/openspec/changes/add-viser-planning-target-set/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: dimos-capability -created: 2026-06-20 diff --git a/openspec/changes/add-viser-planning-target-set/design.md b/openspec/changes/add-viser-planning-target-set/design.md deleted file mode 100644 index a1d98a0712..0000000000 --- a/openspec/changes/add-viser-planning-target-set/design.md +++ /dev/null @@ -1,139 +0,0 @@ -## Context - -DimOS manipulation now treats planning groups as first-class planning units and stores successful results as `GeneratedPlan` artifacts over global joint names. Viser currently lags behind that model: the panel selects one robot, maintains one target, and delegates through robot-scoped convenience calls. That works for the single-arm case, but it does not express a coordinated dual-arm intent where `left_arm/manipulator` and `right_arm/manipulator` should be solved, checked, planned, previewed, and executed as one whole target set. - -The current Viser implementation already has useful pieces: in-process adapter calls, target transform controls, joint sliders, preview animation, and `VisualizationSpec` integration through `WorldMonitor`. This change should preserve those pieces while changing their semantic unit from selected robot to planning target set. - -Root `CONTEXT.md` defines the canonical language: Planning Group, Planning Group Selection, Auxiliary Planning Group, Planning Target Set, Generated Plan, Global Joint Name, and Robot Placement. This design follows those terms. For Viser placement, this change deliberately relies on URDF/xacro-authored placement and does not apply `RobotModelConfig.base_pose` inside Viser. - -## Goals / Non-Goals - -**Goals:** - -- Make the Viser panel group-centric instead of robot-centric. -- Let users establish a Planning Target Set by selecting one or more planning groups. -- Show target gizmos keyed by Planning Group ID for selected pose-targetable groups. -- Treat auxiliary planning groups as normal target-set members without assigned gizmos. -- Make joint panels, IK, feasibility, planning, preview, execute, and freshness whole-set scoped. -- Normalize pose-authored targets into global joint targets via realtime whole-set IK. -- Add multi-target Pink IK behavior for same-robot multiple frame targets and cross-robot grouped solves. -- Keep planning, preview, and execution based on the whole `GeneratedPlan` for the target set. -- Preserve the single-robot workflow as the one-group target-set case. - -**Non-Goals:** - -- Do not add a new CLI command. -- Do not make Viser apply `base_pose` or infer backend weld behavior. -- Do not make IK collision-aware; collision validation and path collision avoidance remain WorldSpec/planner responsibilities. -- Do not add atomic multi-controller execution semantics beyond existing generated-plan projection/dispatch. -- Do not expose normal per-group or per-robot Plan/Preview/Execute controls in the target-set workflow. -- Do not require named left/right/dual-arm presets; a checklist plus “select all manipulators” is enough for the first UI. - -## DimOS Architecture - -### Viser panel and scene - -The Viser panel should maintain a Planning Target Set state rather than a single selected robot state. The target set includes selected group IDs, target authoring state, last valid global target joints, whole-set status, diagnostics, plan freshness, and plan status. - -Current robot visuals and preview ghosts remain robot-keyed because rendering a plan projects to whole robot models. Target transform controls become planning-group-keyed because pose targets belong to planning groups. A pose-targetable selected group gets a gizmo such as `/targets/left_arm/manipulator`; an auxiliary selected group participates without a gizmo. - -The panel should display one unified Target Set joint panel, visually grouped by planning group. Per-group sections are views into one target set, not independent state machines. The action row operates on the whole set. - -### ManipulationModule and adapter boundary - -Viser should not implement IK or target-set feasibility semantics directly. The in-process adapter should expose whole-set operations backed by `ManipulationModule`, such as: - -- evaluate pose targets for a planning target set; -- evaluate joint targets for a planning target set; -- plan the current target set through joint-target planning; -- preview and execute the current whole generated plan. - -Evaluation results should use global joint names as the semantic output. Viser may display local labels inside group sections, but the returned target joint state must be unambiguous and directly usable by planning APIs. - -Pose-authored targets should normalize to joint targets before planning. The panel should call pose target set evaluation during gizmo edits, update the last valid target joints, then call joint target planning when the user plans. Joint edits should call joint target set evaluation, update pose gizmos through FK where available, and update whole-set feasibility. - -### Pink IK - -Pink IK should expose one multi-target pose solve behavior while grouping internally by robot model: - -- Same-robot multiple pose targets: solve one Pink configuration with multiple frame tasks. -- Cross-robot pose targets: solve one Pink problem per robot model and combine the results into one global selected joint target. - -Auxiliary groups participate in the selected joints and seed/target vector but do not receive direct frame tasks. They are still solved, checked, planned, previewed, and executed with the whole target set. - -IK should use the last valid target joints as the seed after target-set initialization. When a group is selected, its target initializes from current state. On IK failure, keep the last valid target joints, mark the whole target set invalid/stale, and disable planning. - -### Planning and execution - -Planning remains joint-target based after target-set evaluation. The whole target set lowers to `plan_to_joint_targets(...)` keyed by planning group. Preview and execute operate on the full generated plan without normal robot filtering in the panel. - -Plan freshness is whole-set scoped. When planning succeeds, store a current-joint snapshot for all selected groups. Execution is enabled only if current selected-group joints still match that snapshot within tolerance. - -### Blueprints and streams - -No stream contract changes are expected. Existing xArm planner/coordinator blueprints remain the manual QA target. Viser should work with `xarm7-planner-coordinator` and dual xArm mock planner/coordinator through visualization config overrides. - -### Skills/MCP and generated registries - -No skill/MCP tool contract changes are expected. No blueprint registry regeneration is expected unless implementation changes blueprint definitions. - -## Decisions - -1. **Use Planning Target Set as the UI semantic unit.** - - Rationale: Once groups are selected, independent per-group UI state causes discrepancies between IK, feasibility, planning, and execution. - - Alternative rejected: separate group cards with independent status/actions. - -2. **Keep Viser placement URDF-authored.** - - Rationale: xArm xacro already encodes attachment placement. Applying `base_pose` in Viser risks double placement and duplicates Drake weld heuristics. - - Alternative rejected: backend placement inference in Viser. - -3. **Key target gizmos by Planning Group ID.** - - Rationale: pose targets belong to planning groups, not robots. This avoids a future mismatch for robots with multiple pose-targetable groups. - - Alternative rejected: robot-keyed target controls. - -4. **Plan through joint targets after evaluation.** - - Rationale: The panel can use pose gizmos naturally while the planner receives one uniform global joint target set. - - Alternative rejected: separate UI modes for pose planning and joint planning. - -5. **Group Pink multi-target solving by robot internally.** - - Rationale: Same-robot multi-frame targets need one Pink configuration. Cross-robot targets can be solved per model and combined without building a combined Pinocchio model. - - Alternative deferred: building one combined Pink model across robots. - -6. **Whole-set status is canonical.** - - Rationale: The Plan button must not be enabled because individual group sections look valid while the combined target set is invalid. - - Alternative rejected: per-group canonical statuses. - -## Safety / Simulation / Replay - -Execution remains gated by existing Viser configuration (`allow_plan_execute`) and manipulation module state. The panel should not execute unless the whole plan is fresh and current selected-group joints match the planning start snapshot. - -Collision checking remains outside IK and in WorldSpec/planner behavior. IK may return kinematically valid targets that planning later rejects due to collision or unavailable path. The UI must surface this as whole-set planning failure, not per-group success. - -Manual QA should use mock xArm blueprints first, especially `xarm7-planner-coordinator` and the dual xArm planner/coordinator, before any hardware test. Hardware tests must keep execution explicitly opt-in. - -Replay behavior is not expected to change except that Viser target-set state should consume the same current joint states as the existing panel. - -## Risks / Trade-offs - -- **Realtime IK cost:** Whole-set IK on every gizmo drag can be expensive. Use latest-request-wins and debounce behavior in the existing evaluation worker pattern. -- **State complexity:** The panel state becomes richer. Mitigate by making Planning Target Set the only authoritative UI state and treating per-group displays as projections. -- **Pink multi-frame correctness:** Same-robot multi-frame IK needs careful frame-task construction and result filtering. Add focused tests before relying on the UI. -- **Cross-robot IK limitation:** Cross-robot grouping combines independent kinematic solves. Inter-robot collision is handled by planning, not IK, so some targets may solve IK but fail planning. -- **Docs drift:** `add-planning-groups` artifacts still contain older terms such as ResolvedPlanningGroup. This change should use current glossary language and avoid reviving stale terms. - -## Migration / Rollout - -1. Add target-set evaluation structures and module/adapter methods while preserving single-group behavior. -2. Update Pink IK multi-target behavior with focused tests. -3. Rework Viser panel state and scene target controls to use planning group IDs. -4. Keep existing single-arm Viser interactions working as one selected group. -5. Add dual xArm mock tests and manual QA. -6. Update user-facing documentation for the target-set workflow. - -Rollback can keep the current single-robot panel path if target-set UI is implemented behind a small internal adapter seam, but the final intended state should be group-centric only. - -## Open Questions - -- Exact Python location/name for the target-set evaluation result type. -- Whether target-set evaluation should be public RPC or only in-process adapter/module API initially. -- How much debouncing is needed for realtime whole-set IK with real robots and larger target sets. diff --git a/openspec/changes/add-viser-planning-target-set/docs.md b/openspec/changes/add-viser-planning-target-set/docs.md deleted file mode 100644 index 79a95c95b3..0000000000 --- a/openspec/changes/add-viser-planning-target-set/docs.md +++ /dev/null @@ -1,32 +0,0 @@ -## User-Facing Docs - -- Update manipulation/Viser usage documentation to describe the Planning Target Set workflow: - - selecting one or more planning groups; - - using group-keyed target gizmos; - - understanding auxiliary groups as selected groups without direct gizmos; - - planning, previewing, and executing the whole target set; - - using the workflow with single xArm and dual xArm mock blueprints. -- If no dedicated Viser usage page exists, add the workflow to the closest manipulation usage or capability document. -- Include an example command for launching xArm planner/coordinator with Viser enabled and execution opt-in clearly marked. - -## Contributor Docs - -- Update manipulation planning contributor notes if they describe robot-scoped Viser behavior or single-target IK assumptions. -- Mention that Viser placement is URDF-authored for this workflow and that `base_pose` is not automatically applied by Viser. - -## Coding-Agent Docs - -- No required `AGENTS.md` update is expected. -- If `docs/coding-agents/` contains manipulation-specific guidance, add a short note that target-set UI state is whole-set scoped and should not reintroduce per-robot Plan/Preview/Execute state. - -## Doc Validation - -- Run link validation for changed docs if available: - - `doclinks` -- If docs contain executable Python snippets, run: - - `md-babel-py run ` -- Run normal formatting/lint checks for any touched docs if configured in the repository. - -## No Docs Needed - -Documentation changes are needed because this change introduces a new user-facing Viser manipulation workflow and changes the mental model from robot selection to planning target sets. diff --git a/openspec/changes/add-viser-planning-target-set/proposal.md b/openspec/changes/add-viser-planning-target-set/proposal.md deleted file mode 100644 index 5379496033..0000000000 --- a/openspec/changes/add-viser-planning-target-set/proposal.md +++ /dev/null @@ -1,53 +0,0 @@ -## Why - -The current Viser manipulation panel is robot-centric: it selects one robot, edits one target, and plans through robot-scoped compatibility APIs. That is not sufficient for dual-arm and multi-group manipulation, where the user intent is a single coordinated target over a planning group selection such as `left_arm/manipulator` plus `right_arm/manipulator`. - -DimOS now has first-class planning groups and generated plans over global joint names. Viser should expose that model naturally: users select a set of planning groups, edit pose gizmos or joint targets as one target set, and plan/preview/execute one whole generated plan. This also creates a clear path for natural pose-based bimanual planning through multi-target Pink IK. - -## What Changes - -- Add a Viser **Planning Target Set** workflow built on planning group selection rather than selected robot. -- Show planning-group-keyed target gizmos for selected pose-targetable groups. -- Treat auxiliary planning groups as members of the same target set without direct gizmo targets. -- Normalize pose-authored targets through whole-set IK into global joint targets, then plan through joint-target planning. -- Add whole-set evaluation semantics for IK, FK/target pose updates, feasibility, plan freshness, preview, and execute. -- Extend Pink IK to support multi-target pose evaluation through one unified API: - - same-robot targets use one Pink solve with multiple frame tasks; - - cross-robot targets are grouped by robot and combined into one global selected joint target. -- Keep collision validation and collision-free path planning in WorldSpec/planner responsibilities, not in IK semantics. -- Keep Viser robot placement URDF-authored for this change; do not infer or apply `base_pose` in Viser. - -## Affected DimOS Surfaces - -- Modules/streams: - - `ManipulationModule` target evaluation helpers and Viser-facing adapter surface. - - Manipulation planning group registry and group-target evaluation paths. - - Pink IK pose-target solving behavior. - - Viser visualization panel, scene target controls, and runtime state. -- Blueprints/CLI: - - No new CLI command is expected. - - Existing xArm planner/coordinator blueprints should be usable with Viser for single-arm and dual-arm mock validation. -- Skills/MCP: - - No direct MCP skill behavior change is expected. -- Hardware/simulation/replay: - - Dual-arm mock xArm planning should support group-target-set preview and planning in Viser. - - Hardware execution remains gated by existing execution controls and coordinator behavior. -- Docs/generated registries: - - Update manipulation/Viser usage docs if present. - - Update OpenSpec/docs language to reflect Planning Target Set and multi-target Pink IK. - -## Capabilities - -### New Capabilities - -- `viser-planning-target-set`: Viser UI behavior for selecting planning groups, authoring whole-set targets, evaluating target sets, and planning/previewing/executing generated plans. - -### Modified Capabilities - -- `manipulation-planning-groups`: Planning group workflows gain whole-set target authoring/evaluation semantics and multi-target Pink IK support. - -## Impact - -Users get a natural dual-arm Viser workflow: select both arm planning groups, manipulate group-keyed target gizmos, watch the whole target set solve to joint targets, then plan/preview/execute one generated plan. Developers get a cleaner module/UI boundary where Viser owns editing state and `ManipulationModule` owns target-set semantics. - -Compatibility risks are mostly UI/API surface changes around the in-process Viser adapter and target evaluation helpers. Existing single-robot Viser workflows should remain usable as the one-group case of the target-set workflow. Testing should cover single-arm regression, dual xArm mock target-set selection, group-keyed gizmo visibility, whole-set IK failure/freshness behavior, Pink multi-target IK, and plan/preview/execute whole-set scope. diff --git a/openspec/changes/add-viser-planning-target-set/specs/manipulation-planning-groups/spec.md b/openspec/changes/add-viser-planning-target-set/specs/manipulation-planning-groups/spec.md deleted file mode 100644 index e50237ad44..0000000000 --- a/openspec/changes/add-viser-planning-target-set/specs/manipulation-planning-groups/spec.md +++ /dev/null @@ -1,71 +0,0 @@ -## ADDED Requirements - -### Requirement: Whole-set pose target evaluation - -DimOS SHALL evaluate pose targets for a planning target set and return a global selected joint target when evaluation succeeds. - -#### Scenario: dual-arm pose targets evaluate to global joints -- **GIVEN** a planning target set includes `left_arm/manipulator` and `right_arm/manipulator` -- **AND** each selected group has an assigned pose target -- **WHEN** DimOS evaluates the pose target set -- **THEN** DimOS returns a target joint state using global joint names for both selected groups -- **AND** the result can be used as the goal for joint-target planning - -#### Scenario: auxiliary group participates without pose target -- **GIVEN** a planning target set includes one pose-targeted group and one auxiliary group -- **WHEN** DimOS evaluates the pose target set -- **THEN** the auxiliary group's selected joints participate in the returned target joint state -- **AND** DimOS does not require a direct pose target for the auxiliary group - -### Requirement: Multi-target Pink IK behavior - -DimOS SHALL support multi-target pose evaluation with Pink IK for same-robot and cross-robot planning target sets. - -#### Scenario: same robot multiple frame targets -- **GIVEN** one robot has multiple selected pose-targetable planning groups -- **WHEN** DimOS evaluates pose targets for those groups using Pink IK -- **THEN** DimOS solves the targets as one same-robot multi-frame IK problem -- **AND** returns one global selected joint target for the effective planning group selection - -#### Scenario: cross robot pose targets -- **GIVEN** a planning target set contains pose targets for planning groups on different robots -- **WHEN** DimOS evaluates the target set using Pink IK -- **THEN** DimOS evaluates the targets per robot model as needed -- **AND** combines successful results into one global selected joint target for the whole target set - -#### Scenario: IK does not own collision semantics -- **GIVEN** Pink IK returns a kinematically valid target joint state -- **WHEN** collision validation or path planning is required -- **THEN** DimOS performs collision checks through the planning world or planner responsibilities -- **AND** the IK result alone is not treated as proof that execution is collision-free - -### Requirement: Whole-set joint target evaluation - -DimOS SHALL evaluate joint targets for a planning target set as one whole selected target. - -#### Scenario: joint target evaluation returns poses for selected groups -- **GIVEN** a planning target set has global target joints for selected planning groups -- **WHEN** DimOS evaluates the joint target set -- **THEN** DimOS returns whole-set validity -- **AND** returns target poses for pose-targetable selected groups when available - -#### Scenario: invalid joint target blocks whole set -- **GIVEN** any selected planning group has missing, extra, or invalid target joints -- **WHEN** DimOS evaluates the joint target set -- **THEN** the whole target set is invalid -- **AND** planning is not enabled for the target set - -### Requirement: Target-set seed continuity - -DimOS SHALL use last valid target joints as the preferred seed for subsequent whole-set IK evaluation. - -#### Scenario: current state initializes target set -- **GIVEN** a planning group is newly added to a target set -- **WHEN** DimOS initializes target-set evaluation state -- **THEN** DimOS uses current joints for that group as the initial target and seed - -#### Scenario: subsequent IK uses last valid target -- **GIVEN** a planning target set has a last valid solved target joint state -- **WHEN** a subsequent pose edit triggers IK evaluation -- **THEN** DimOS uses the last valid target joints as the preferred seed -- **AND** planning still uses actual current state as the start when a plan is requested diff --git a/openspec/changes/add-viser-planning-target-set/specs/viser-planning-target-set/spec.md b/openspec/changes/add-viser-planning-target-set/specs/viser-planning-target-set/spec.md deleted file mode 100644 index d027115563..0000000000 --- a/openspec/changes/add-viser-planning-target-set/specs/viser-planning-target-set/spec.md +++ /dev/null @@ -1,115 +0,0 @@ -## ADDED Requirements - -### Requirement: Planning target set selection - -Viser SHALL let users establish a planning target set by selecting one or more manipulation planning groups. - -#### Scenario: select multiple manipulator groups -- **GIVEN** a Viser manipulation scene contains `left_arm/manipulator` and `right_arm/manipulator` -- **WHEN** the user selects both planning groups -- **THEN** Viser treats them as one planning target set -- **AND** subsequent IK, feasibility, planning, preview, execute, and stale-state checks are scoped to the whole target set - -#### Scenario: select all manipulators convenience action -- **GIVEN** a Viser manipulation scene contains multiple manipulator planning groups -- **WHEN** the user chooses the select-all-manipulators action -- **THEN** all manipulator planning groups become members of the current planning target set -- **AND** the user can still adjust the selection explicitly - -### Requirement: Group-keyed target controls - -Viser SHALL key pose target controls by Planning Group ID for selected pose-targetable planning groups. - -#### Scenario: selected pose-targetable group shows gizmo -- **GIVEN** a selected planning group has a pose target frame -- **WHEN** Viser renders the planning target set -- **THEN** Viser shows a target gizmo associated with that Planning Group ID -- **AND** moving the gizmo edits that group's pose target within the whole target set - -#### Scenario: unselected group hides gizmo -- **GIVEN** a planning group is not part of the planning target set -- **WHEN** Viser renders target controls -- **THEN** Viser does not show a target gizmo for that group -- **AND** that group does not contribute target joints to the planning target set - -#### Scenario: auxiliary group has no assigned gizmo -- **GIVEN** a planning group is selected as an auxiliary member of the planning target set -- **WHEN** Viser renders target controls -- **THEN** Viser does not assign a direct pose gizmo to that auxiliary group -- **AND** the auxiliary group still participates in whole-set IK, feasibility, planning, preview, and execute behavior - -### Requirement: Target initialization from current state - -Viser SHALL initialize newly selected planning groups from current robot state. - -#### Scenario: selecting a group is motion-neutral -- **GIVEN** a planning group is not selected -- **WHEN** the user adds the group to the planning target set -- **THEN** Viser initializes that group's target joints from current joints -- **AND** if the group is pose-targetable, its target gizmo starts at the current group pose -- **AND** adding the group alone does not create a motion target away from current state - -### Requirement: Whole-set target authoring - -Viser SHALL treat pose gizmos and joint controls as views over one planning target set. - -#### Scenario: pose edit updates target joints -- **GIVEN** a planning target set has one or more selected pose-targetable groups -- **WHEN** the user moves any selected group's target gizmo -- **THEN** Viser requests whole-set IK evaluation for all active pose targets -- **AND** Viser updates the target-set joint controls from the returned global target joints when evaluation succeeds - -#### Scenario: joint edit updates pose targets -- **GIVEN** a planning target set has target joints and pose-targetable groups -- **WHEN** the user edits target joints -- **THEN** Viser requests whole-set joint target evaluation -- **AND** Viser updates visible pose gizmos from the evaluated group poses when available - -### Requirement: Whole-set status and failures - -Viser SHALL expose one canonical whole-set status for target validity and plan readiness. - -#### Scenario: IK failure keeps last valid target -- **GIVEN** a planning target set has a last valid solved target joint state -- **WHEN** realtime IK evaluation fails for a subsequent gizmo edit -- **THEN** Viser keeps the last valid target joints -- **AND** marks the planning target set invalid or stale -- **AND** disables planning until a whole-set evaluation succeeds again - -#### Scenario: per-group diagnostics are explanatory -- **GIVEN** a planning target set evaluation returns diagnostics for individual groups -- **WHEN** Viser displays target-set status -- **THEN** the whole-set status controls whether planning is enabled -- **AND** per-group diagnostics are displayed only as explanatory details - -### Requirement: Whole-set planning actions - -Viser SHALL plan, preview, execute, clear, and report freshness for the whole planning target set. - -#### Scenario: plan target set through joint targets -- **GIVEN** a planning target set has valid global target joints -- **WHEN** the user requests planning -- **THEN** Viser requests joint-target planning for the selected planning groups -- **AND** the request represents the whole target set rather than an individual robot or group - -#### Scenario: preview and execute full generated plan -- **GIVEN** planning succeeds for a planning target set -- **WHEN** the user previews or executes from Viser -- **THEN** Viser acts on the full generated plan for the target set -- **AND** the normal UI does not expose per-robot or per-group preview/execute actions - -#### Scenario: execute requires whole-set freshness -- **GIVEN** a generated plan was created for a planning target set -- **WHEN** current joints for any selected planning group drift from the plan start snapshot beyond tolerance -- **THEN** Viser marks the plan stale for the whole target set -- **AND** disables execute for the whole plan - -### Requirement: URDF-authored visual placement - -Viser SHALL rely on authored URDF/xacro placement for robot visuals in this workflow. - -#### Scenario: dual xArm visual placement is not double-applied -- **GIVEN** dual xArm robot models encode placement in their authored URDF/xacro output -- **WHEN** Viser registers the robots for visualization -- **THEN** Viser renders the prepared URDFs as authored -- **AND** Viser does not apply `base_pose` as an additional implicit visual transform diff --git a/openspec/changes/add-viser-planning-target-set/tasks.md b/openspec/changes/add-viser-planning-target-set/tasks.md deleted file mode 100644 index bfeffc9220..0000000000 --- a/openspec/changes/add-viser-planning-target-set/tasks.md +++ /dev/null @@ -1,45 +0,0 @@ -## 1. Target-set evaluation model and module boundary - -- [x] 1.1 Define a target-set evaluation result shape with whole-set status, message, group IDs, global target joints, optional group diagnostics, and pose outputs for pose-targetable groups. -- [x] 1.2 Add ManipulationModule whole-set pose evaluation for `Mapping[PlanningGroupID, PoseStamped]` plus auxiliary group IDs, returning global selected joint targets. -- [x] 1.3 Add ManipulationModule whole-set joint evaluation for `Mapping[PlanningGroupID, JointState]`, including exact target validation and FK pose outputs for pose-targetable groups. -- [x] 1.4 Update the in-process Viser adapter to expose whole-set evaluation, target-set planning, whole-plan preview, and whole-plan execution helpers. -- [x] 1.5 Preserve existing one-robot behavior as the one-planning-group target-set case. - -## 2. Pink multi-target IK - -- [x] 2.1 Extend Pink IK pose-target solving to accept multiple pose-targeted planning groups in one request. -- [x] 2.2 Implement same-robot multi-frame Pink solving with multiple frame tasks in one Pink configuration. -- [x] 2.3 Implement cross-robot grouping for Pink IK by solving per robot model and combining results into one global selected joint target. -- [x] 2.4 Ensure auxiliary planning groups participate in the selected joints and seed/target result without receiving direct frame tasks. -- [x] 2.5 Keep collision semantics outside IK; surface kinematic success separately from collision/path-planning success. -- [x] 2.6 Add Pink IK tests for single-target regression, same-robot multi-frame targets, cross-robot targets, auxiliary groups, and global joint-name output. - -## 3. Viser target-set UI and scene - -- [x] 3.1 Replace robot-centric panel state with Planning Target Set state: selected group IDs, pose targets, global target joints, last valid target joints, whole-set status, diagnostics, plan status, and start snapshots. -- [x] 3.2 Add a planning-group checklist and a select-all-manipulators action. -- [x] 3.3 Make target controls planning-group-keyed; show gizmos only for selected pose-targetable groups and hide gizmos for unselected or auxiliary-only groups. -- [x] 3.4 Render one grouped Target Set joint panel with sections per selected planning group and one whole-set status/action row. -- [x] 3.5 Make pose gizmo edits trigger latest-request-wins whole-set IK evaluation, updating target joints on success and keeping last valid target joints on failure. -- [x] 3.6 Make joint edits trigger whole-set joint evaluation and update visible pose gizmos through FK outputs. -- [x] 3.7 Make Plan/Preview/Execute/Clear actions operate on the whole target set, with no normal per-robot or per-group action controls. -- [x] 3.8 Keep Viser robot placement URDF-authored; do not apply `base_pose` during scene registration. - -## 4. Documentation - -- [x] 4.1 Update manipulation/Viser user documentation with the Planning Target Set workflow and xArm launch examples. -- [x] 4.2 Document auxiliary groups as selected target-set members without assigned gizmos. -- [x] 4.3 Document that Viser uses URDF-authored placement and does not implicitly apply `base_pose`. -- [x] 4.4 Update contributor/coding-agent docs only if they currently describe robot-centric Viser planning or per-robot preview/execute state. - -## 5. Verification - -- [x] 5.1 Run `openspec validate add-viser-planning-target-set`. -- [x] 5.2 Run focused Pink IK tests, including new multi-target cases. -- [x] 5.3 Run focused Viser tests for target-set state, group-keyed gizmos, whole-set status, plan freshness, and whole-plan preview/execute actions. -- [x] 5.4 Run manipulation unit tests covering module whole-set evaluation and planning through joint targets. -- [ ] 5.5 Run xArm single-robot Viser manual QA with `xarm7-planner-coordinator` to verify the one-group workflow still works. -- [ ] 5.6 Run dual xArm mock Viser manual QA to verify selecting both manipulators, moving both gizmos, planning, previewing, and clearing the whole target set. -- [x] 5.7 Run docs validation commands for changed docs, including `doclinks` when available and `md-babel-py run ` for executable snippets. -- [x] 5.8 Run ruff/focused lint checks for touched manipulation and visualization files. diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 62a72bba63..0000000000 --- a/openspec/config.yaml +++ /dev/null @@ -1,45 +0,0 @@ -schema: dimos-capability - -context: | - DimOS is a robotics operating system for generalist robots. Modules communicate - through typed streams (`In[T]`, `Out[T]`) over LCM, SHM, ROS, DDS, or other - transports. Blueprints compose modules into runnable robot stacks. Skills are - `@skill`-annotated RPC methods exposed to agents and MCP clients. - - Terminology boundary: - - "OpenSpec spec" means a behavior specification under `openspec/specs/`. - - "DimOS Spec" means a Python Protocol/RPC contract in `*_spec.py` files, - usually inheriting `dimos.spec.utils.Spec` and `typing.Protocol`. - Keep these separate. OpenSpec specs describe observable behavior; DimOS Specs - describe code-level module interfaces. - - OpenSpec specs should capture current behavior, user/developer-visible - outcomes, public CLI/API/tool surfaces, robot safety constraints, and testable - scenarios. Put implementation choices, class names, module wiring, generated - registry updates, and rollout details in `design.md` or `tasks.md`. - - Documentation lives in: - - `docs/usage/` for user-facing concepts and APIs. - - `docs/capabilities/` for capability and platform guides. - - `docs/development/` for contributor process. - - `docs/coding-agents/` and `AGENTS.md` for coding-agent guidance. - -rules: - proposal: - - "Identify affected DimOS surfaces: modules, streams, blueprints, CLI, skills/MCP, docs, hardware, simulation, replay, or generated registries." - - Use capability names that match behavior domains, not Python class names. - - Mark hardware safety or public API/CLI changes explicitly. - specs: - - Write behavior-first requirements; avoid implementation detail unless it is externally observable. - - Every requirement must include at least one `#### Scenario:` block with concrete observable outcomes. - - Use "OpenSpec capability spec" when prose might otherwise be confused with DimOS Python `Spec` Protocols. - design: - - Call out DimOS `Spec` Protocols, adapter Protocols, blueprint composition, stream names/types, and skill/MCP exposure when relevant. - - Mention generated files and required regeneration commands, especially `pytest dimos/robot/test_all_blueprints_generation.py` for blueprint registry changes. - - Include hardware/simulation/replay assumptions and safety constraints for robot-facing work. - docs: - - List user-facing docs, contributor docs, coding-agent docs, and AGENTS.md updates required by the change. - - Include documentation validation commands for changed docs, such as `doclinks` and `md-babel-py run ` where applicable. - tasks: - - Include verification tasks for OpenSpec validation, relevant pytest targets, type checks when needed, and manual QA through the user-facing surface. - - Add registry generation tasks when blueprint names, module classes, or generated registry inputs change. diff --git a/openspec/schemas/dimos-capability/schema.yaml b/openspec/schemas/dimos-capability/schema.yaml deleted file mode 100644 index fedb7964ee..0000000000 --- a/openspec/schemas/dimos-capability/schema.yaml +++ /dev/null @@ -1,128 +0,0 @@ -name: dimos-capability -version: 1 -description: DimOS capability workflow - proposal → specs/design/docs → tasks -artifacts: - - id: proposal - generates: proposal.md - description: DimOS change proposal covering intent, scope, capability impact, and affected robot/software surfaces - template: proposal.md - instruction: | - Create the proposal document that establishes WHY this change is needed and what DimOS behavior it affects. - - Sections: - - **Why**: 1-2 concise paragraphs on the problem or opportunity. Explain why the change matters now. - - **What Changes**: Bullet list of added, modified, or removed behavior. Mark public API/CLI or hardware-safety breaking changes with **BREAKING**. - - **Affected DimOS Surfaces**: Identify modules, streams, blueprints, CLI commands, skills/MCP tools, docs, hardware, simulation, replay, generated registries, or external protocols touched by the change. - - **Capabilities**: Identify which OpenSpec capability specs will be created or modified: - - **New Capabilities**: List behavior domains introduced by the change. Each becomes `specs//spec.md`. Use kebab-case names (for example, `agent-skills-mcp`, `blueprint-composition`, `manipulation-stack`). - - **Modified Capabilities**: List existing `openspec/specs//` entries whose requirements change. Only include spec-level behavior changes, not implementation-only refactors. - - **Impact**: Summarize user/developer impact, compatibility risks, dependency changes, documentation updates, and test/QA scope. - - Keep proposals concise. Do not include line-by-line implementation details; put architecture and rollout decisions in `design.md`. - requires: [] - - id: specs - generates: specs/**/*.md - description: Behavior-first OpenSpec capability delta specifications - template: spec.md - instruction: | - Create OpenSpec capability specs that define WHAT DimOS should do, not how it is implemented. - - Create one delta spec file per capability listed in proposal.md: - - New capabilities: use `specs//spec.md` with the exact kebab-case name from the proposal. - - Modified capabilities: use the existing folder from `openspec/specs//`. - - Use these delta sections as `##` headers: - - **ADDED Requirements**: New externally observable behavior. - - **MODIFIED Requirements**: Changed behavior. Include the full updated requirement block, not a partial patch. - - **REMOVED Requirements**: Deprecated behavior. Include **Reason** and **Migration**. - - **RENAMED Requirements**: Name-only changes. Use FROM:/TO: format. - - Requirement format: - - Use `### Requirement: `. - - Use SHALL/MUST for normative requirements. - - Include at least one `#### Scenario: ` per requirement. Scenario headings MUST use exactly four `#` characters. - - Prefer `- **GIVEN**`, `- **WHEN**`, `- **THEN**`, and `- **AND**` bullets. - - Cover happy path plus meaningful edge/error/safety cases. - - DimOS-specific guidance: - - Specify user/developer-visible behavior, robot outcomes, CLI behavior, skill/MCP tool behavior, stream contracts, safety constraints, and compatibility expectations. - - Avoid Python class names, private module internals, transport implementation choices, and generated-file details unless those details are observable API contracts. - - Use "OpenSpec capability spec" in prose when needed to avoid confusion with DimOS Python `Spec` Protocols. - - If the behavior only changes implementation and not observable requirements, do not create a spec delta. - requires: - - proposal - - id: design - generates: design.md - description: DimOS technical design and architecture decisions - template: design.md - instruction: | - Create the design document that explains HOW the change should be implemented in DimOS. - - Include design.md for cross-module changes, new robot/hardware integration, new public interfaces, new dependencies, safety-sensitive behavior, generated registry changes, or unclear architecture. - - Sections: - - **Context**: Current state, relevant modules/blueprints/docs, and constraints. - - **Goals / Non-Goals**: What the design achieves and explicitly excludes. - - **DimOS Architecture**: Modules, streams, transports, blueprints, RPC/module refs, DimOS `Spec` Protocols, adapter Protocols, skills/MCP exposure, CLI entry points, and generated registries involved. - - **Decisions**: Key choices with rationale and alternatives considered. - - **Safety / Simulation / Replay**: Hardware assumptions, sim/replay behavior, safety constraints, and manual QA surface. - - **Risks / Trade-offs**: Known risks and mitigations. - - **Migration / Rollout**: Compatibility, generated files, docs, and deployment steps. - - **Open Questions**: Outstanding decisions or unknowns. - - Reference proposal.md for intent and specs for behavior. Keep line-by-line work in tasks.md. - requires: - - proposal - - id: docs - generates: docs.md - description: Documentation impact plan for user, contributor, and coding-agent docs - template: docs.md - instruction: | - Create the documentation impact plan for the change. - - Sections: - - **User-Facing Docs**: Updates under `docs/usage/`, `docs/capabilities/`, `docs/platforms/`, or README files. - - **Contributor Docs**: Updates under `docs/development/`. - - **Coding-Agent Docs**: Updates under `docs/coding-agents/` or `AGENTS.md`. - - **Doc Validation**: Commands needed for changed docs, such as `doclinks`, `md-babel-py run `, and `bin/gen-diagrams`. - - **No Docs Needed**: If no docs are needed, explain why. - - Match `docs/development/writing_docs.md`: contributor-only docs belong in `docs/development`; user-facing behavior belongs in `docs/usage` or `docs/capabilities`. - requires: - - proposal - - id: tasks - generates: tasks.md - description: Implementation, validation, docs, and manual-QA checklist - template: tasks.md - instruction: | - Create the implementation checklist. The apply phase parses checkbox format, so every actionable task MUST use `- [ ]`. - - Guidelines: - - Group tasks under numbered `##` headings. - - Each task must be `- [ ] X.Y Task description`. - - Keep tasks small enough to complete in one focused session. - - Order tasks by dependency. - - Include docs and validation tasks from docs.md. - - Include generated registry tasks when blueprints or module registry inputs change. - - Include manual QA through the actual user surface: CLI, TUI, HTTP API, MCP tool, simulation/replay blueprint, hardware procedure, or library driver. - - Typical DimOS validation tasks: - - Run `openspec validate `. - - Run focused pytest targets for changed modules. - - Run `pytest dimos/robot/test_all_blueprints_generation.py` when blueprint registry output may change. - - Run docs validation commands for changed docs. - - Run lints/types when the touched area requires them. - - Reference specs for WHAT, design for HOW, and docs.md for documentation work. - requires: - - specs - - design - - docs -apply: - requires: - - tasks - tracks: tasks.md - instruction: | - Read proposal.md, specs, design.md, docs.md, and tasks.md before editing code. - Work through pending tasks, mark checkboxes complete as they finish, and keep artifacts current when implementation changes the plan. - Verify with OpenSpec validation, focused tests, docs checks, and manual QA through the relevant DimOS surface. diff --git a/openspec/schemas/dimos-capability/templates/design.md b/openspec/schemas/dimos-capability/templates/design.md deleted file mode 100644 index 25031ceb8b..0000000000 --- a/openspec/schemas/dimos-capability/templates/design.md +++ /dev/null @@ -1,35 +0,0 @@ -## Context - - - -## Goals / Non-Goals - -**Goals:** - - -**Non-Goals:** - - -## DimOS Architecture - - - -## Decisions - - - -## Safety / Simulation / Replay - - - -## Risks / Trade-offs - - - -## Migration / Rollout - - - -## Open Questions - - diff --git a/openspec/schemas/dimos-capability/templates/docs.md b/openspec/schemas/dimos-capability/templates/docs.md deleted file mode 100644 index d274aed653..0000000000 --- a/openspec/schemas/dimos-capability/templates/docs.md +++ /dev/null @@ -1,19 +0,0 @@ -## User-Facing Docs - - - -## Contributor Docs - - - -## Coding-Agent Docs - - - -## Doc Validation - - - -## No Docs Needed - - diff --git a/openspec/schemas/dimos-capability/templates/proposal.md b/openspec/schemas/dimos-capability/templates/proposal.md deleted file mode 100644 index 98d409e8de..0000000000 --- a/openspec/schemas/dimos-capability/templates/proposal.md +++ /dev/null @@ -1,32 +0,0 @@ -## Why - - - -## What Changes - - - -## Affected DimOS Surfaces - - -- Modules/streams: -- Blueprints/CLI: -- Skills/MCP: -- Hardware/simulation/replay: -- Docs/generated registries: - -## Capabilities - -### New Capabilities - -- ``: - -### Modified Capabilities - -- ``: - -## Impact - - diff --git a/openspec/schemas/dimos-capability/templates/spec.md b/openspec/schemas/dimos-capability/templates/spec.md deleted file mode 100644 index afc0c1ff58..0000000000 --- a/openspec/schemas/dimos-capability/templates/spec.md +++ /dev/null @@ -1,16 +0,0 @@ -## ADDED Requirements - -### Requirement: - - -#### Scenario: -- **GIVEN** -- **WHEN** -- **THEN** -- **AND** - - diff --git a/openspec/schemas/dimos-capability/templates/tasks.md b/openspec/schemas/dimos-capability/templates/tasks.md deleted file mode 100644 index b38fcdfabb..0000000000 --- a/openspec/schemas/dimos-capability/templates/tasks.md +++ /dev/null @@ -1,15 +0,0 @@ -## 1. Implementation - -- [ ] 1.1 -- [ ] 1.2 - -## 2. Documentation - -- [ ] 2.1 - -## 3. Verification - -- [ ] 3.1 Run `openspec validate ` -- [ ] 3.2 Run focused tests for changed code -- [ ] 3.3 Run docs validation commands for changed docs -- [ ] 3.4 Manually QA through the relevant DimOS surface (CLI, MCP, simulation/replay, hardware procedure, HTTP API, or library driver) From 12cf85d55edec7daeff0bc7f6921bb9117010601 Mon Sep 17 00:00:00 2001 From: cc <55869557+TomCC7@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:54:40 -0700 Subject: [PATCH 068/110] Apply suggestions from code review Co-authored-by: cc <55869557+TomCC7@users.noreply.github.com> --- docs/capabilities/manipulation/readme.md | 44 ------------------------ 1 file changed, 44 deletions(-) diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index 603f9e446e..7aeb9d41de 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -129,56 +129,12 @@ touching `WorldSpec`, IK, planner objects, or live Drake contexts directly. Rend mutable joint state/path containers at the read boundary, then updates the Viser scene after manipulation/world accessors have returned. -#### Viser Planning Target Set workflow - -The Viser manipulation panel is planning-group centric. Select one or more planning groups -to form a **Planning Target Set**; IK, feasibility checks, planning, preview, execute, -clear-plan, and plan freshness are scoped to that whole set. A single xArm uses the same -workflow as a one-group target set, while dual-arm stacks can select both manipulators and -plan them together. - -- Use the planning-group checklist to add or remove groups. **Select all manipulators** - selects every planning group named `manipulator`. -- Pose target gizmos are keyed by planning group ID. Moving any selected pose gizmo triggers - whole-set IK evaluation and updates the global target joints when IK succeeds. -- Joint sliders are grouped by planning group. Editing joints triggers whole-set joint - evaluation and refreshes visible pose gizmos from FK outputs when available. -- Auxiliary groups are selected target-set members without direct gizmos. Their joints still - participate in IK seeds, target joints, feasibility, planning, preview, and execute. -- The panel exposes one Plan, Preview, Execute, Cancel, and Clear row for the whole target set; - normal operation does not expose per-robot preview or execute controls. - -Single xArm Viser example: - -```bash -uv run dimos run xarm7-planner-coordinator \ - -o manipulationmodule.visualization.backend=viser -``` - -Enable browser-panel execution only when an operator is intentionally allowed to execute plans: - -```bash -uv run dimos run xarm7-planner-coordinator \ - -o manipulationmodule.visualization.backend=viser \ - -o manipulationmodule.visualization.allow_plan_execute=true -``` - -Dual-arm mock Viser example: - -```bash -uv run dimos run dual-xarm6-planner-coordinator -``` - External manipulation visualizers are initialized from a backend-neutral planning-scene snapshot after the planning world has added its robots. This snapshot maps world robot IDs to `RobotModelConfig` metadata so Viser can prepare current, target, and transient preview robot visuals without `WorldMonitor` depending on Viser-specific hooks. Embedded Meshcat visualization does not need extra setup because it observes the Drake world directly. -Viser renders robot placement as authored in the prepared URDF/xacro output. It does not apply -`RobotModelConfig.base_pose` as an additional implicit visual transform, which avoids -double-applying placement for multi-robot models that already encode offsets in URDF/xacro. - Panel execution is opt-in. Leave `allow_plan_execute=False` unless the operator intentionally wants the browser panel to call the existing manipulation execution path. From 13f3bd0eef7f9ca477111dbf55760ad8fe4f25c9 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 20:54:42 -0700 Subject: [PATCH 069/110] refactor: remove robot catalog package --- dimos/manipulation/blueprints.py | 231 ++++++++++++++------ dimos/robot/catalog/test_ufactory.py | 40 ---- dimos/robot/catalog/ufactory.py | 174 --------------- dimos/robot/manipulators/xarm/blueprints.py | 95 +++++--- 4 files changed, 229 insertions(+), 311 deletions(-) delete mode 100644 dimos/robot/catalog/test_ufactory.py delete mode 100644 dimos/robot/catalog/ufactory.py diff --git a/dimos/manipulation/blueprints.py b/dimos/manipulation/blueprints.py index 6c4b2764d3..6bdb148dfb 100644 --- a/dimos/manipulation/blueprints.py +++ b/dimos/manipulation/blueprints.py @@ -32,56 +32,155 @@ """ import math +from pathlib import Path +from typing import Any from dimos.agents.mcp.mcp_client import McpClient from dimos.agents.mcp.mcp_server import McpServer -from dimos.control.coordinator import ControlCoordinator +from dimos.control.blueprints._hardware import XARM7_SIM_PATH, manipulator +from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import autoconnect from dimos.core.global_config import global_config from dimos.core.transport import LCMTransport from dimos.hardware.sensors.camera.realsense.camera import RealSenseCamera from dimos.manipulation.manipulation_module import ManipulationModule from dimos.manipulation.pick_and_place_module import PickAndPlaceModule +from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Transform import Transform from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.sensor_msgs.JointState import JointState from dimos.perception.object_scene_registration import ObjectSceneRegistrationModule -from dimos.robot.catalog.ufactory import xarm6 as _catalog_xarm6, xarm7 as _catalog_xarm7 +from dimos.simulation.engines.mujoco_sim_module import MujocoSimModule +from dimos.utils.data import LfsPath +from dimos.visualization.rerun.bridge import RerunBridgeModule + +XARM_GRIPPER_COLLISION_EXCLUSIONS: list[tuple[str, str]] = [ + ("right_inner_knuckle", "right_outer_knuckle"), + ("left_inner_knuckle", "left_outer_knuckle"), + ("right_inner_knuckle", "right_finger"), + ("left_inner_knuckle", "left_finger"), + ("left_finger", "right_finger"), + ("left_outer_knuckle", "right_outer_knuckle"), + ("left_inner_knuckle", "right_inner_knuckle"), + ("left_outer_knuckle", "right_finger"), + ("right_outer_knuckle", "left_finger"), + ("xarm_gripper_base_link", "left_inner_knuckle"), + ("xarm_gripper_base_link", "right_inner_knuckle"), + ("xarm_gripper_base_link", "left_finger"), + ("xarm_gripper_base_link", "right_finger"), + ("link6", "xarm_gripper_base_link"), + ("link6", "left_outer_knuckle"), + ("link6", "right_outer_knuckle"), +] + +_XARM_MODEL_PATH = LfsPath("xarm_description") / "urdf/xarm_device.urdf.xacro" +_XARM_PACKAGE_PATHS: dict[str, Path] = {"xarm_description": LfsPath("xarm_description")} + + +def _base_pose( + x: float = 0.0, + y: float = 0.0, + z: float = 0.0, + pitch: float = 0.0, +) -> PoseStamped: + half_pitch = pitch / 2.0 + return PoseStamped( + position=Vector3(x=x, y=y, z=z), + orientation=Quaternion(0.0, math.sin(half_pitch), 0.0, math.cos(half_pitch)), + ) + + +def _make_xarm_model_config( + name: str, + dof: int, + *, + add_gripper: bool = True, + x_offset: float = 0.0, + y_offset: float = 0.0, + z_offset: float = 0.0, + pitch: float = 0.0, + tf_extra_links: list[str] | None = None, + home_joints: list[float] | None = None, + pre_grasp_offset: float = 0.10, + **overrides: Any, +) -> RobotModelConfig: + xacro_args = { + "dof": str(dof), + "limited": "true", + "attach_xyz": "0 0 0", + "attach_rpy": "0 0 0", + } + if add_gripper: + xacro_args["add_gripper"] = "true" + + defaults: dict[str, Any] = { + "name": name, + "model_path": _XARM_MODEL_PATH, + "base_pose": _base_pose(x_offset, y_offset, z_offset, pitch), + "strip_model_world_joint": True, + "joint_names": [f"joint{i}" for i in range(1, dof + 1)], + "end_effector_link": "link_tcp" if add_gripper else f"link{dof}", + "base_link": "link_base", + "package_paths": _XARM_PACKAGE_PATHS, + "xacro_args": xacro_args, + "auto_convert_meshes": True, + "collision_exclusion_pairs": XARM_GRIPPER_COLLISION_EXCLUSIONS if add_gripper else [], + "coordinator_task_name": f"traj_{name}", + "gripper_hardware_id": name if add_gripper else None, + "tf_extra_links": tf_extra_links or [], + "home_joints": home_joints or [0.0] * dof, + "pre_grasp_offset": pre_grasp_offset, + } + defaults.update(overrides) + return RobotModelConfig(**defaults) + + +def _make_xarm6_model_config(name: str = "arm", **kwargs: Any) -> RobotModelConfig: + return _make_xarm_model_config(name, 6, **kwargs) + + +def _make_xarm7_model_config(name: str = "arm", **kwargs: Any) -> RobotModelConfig: + return _make_xarm_model_config(name, 7, **kwargs) -# Single XArm6 planner (standalone, no coordinator) -_xarm6_planner_cfg = _catalog_xarm6( - name="arm", - adapter_type="xarm" if global_config.xarm6_ip else "mock", - address=global_config.xarm6_ip, -) +def _trajectory_task(name: str, joint_names: list[str]) -> TaskConfig: + return TaskConfig( + name=f"traj_{name}", + type="trajectory", + joint_names=joint_names, + priority=10, + ) + + +# Single XArm6 planner (standalone, no coordinator) xarm6_planner_only = ManipulationModule.blueprint( - robots=[_xarm6_planner_cfg.to_robot_model_config()], + robots=[_make_xarm6_model_config(name="arm")], planning_timeout=10.0, visualization={"backend": "meshcat"}, ) # Dual XArm6 planner + coordinator with Viser execution UI. -_left_arm_cfg = _catalog_xarm6( - name="left_arm", +_left_arm_hw = manipulator( + "left_arm", + 6, adapter_type="xarm" if global_config.xarm6_ip else "mock", address=global_config.xarm6_ip, - y_offset=0.5, ) -_right_arm_cfg = _catalog_xarm6( - name="right_arm", +_right_arm_hw = manipulator( + "right_arm", + 6, adapter_type="xarm" if global_config.xarm6_ip else "mock", address=global_config.xarm6_ip, - y_offset=-0.5, ) dual_xarm6_planner_coordinator = autoconnect( ManipulationModule.blueprint( robots=[ - _left_arm_cfg.to_robot_model_config(), - _right_arm_cfg.to_robot_model_config(), + _make_xarm6_model_config(name="left_arm", y_offset=0.5), + _make_xarm6_model_config(name="right_arm", y_offset=-0.5), ], planning_timeout=10.0, visualization={"backend": "viser", "allow_plan_execute": True}, @@ -91,12 +190,12 @@ publish_joint_state=True, joint_state_frame_id="coordinator", hardware=[ - _left_arm_cfg.to_hardware_component(), - _right_arm_cfg.to_hardware_component(), + _left_arm_hw, + _right_arm_hw, ], tasks=[ - _left_arm_cfg.to_task_config(), - _right_arm_cfg.to_task_config(), + _trajectory_task("left_arm", _left_arm_hw.joints), + _trajectory_task("right_arm", _right_arm_hw.joints), ], ), ).transports( @@ -108,16 +207,17 @@ # Single XArm7 planner + coordinator (uses real hardware when XARM7_IP is set) # Usage: XARM7_IP= dimos run xarm7-planner-coordinator -_xarm7_cfg = _catalog_xarm7( - name="arm", +_xarm7_hw = manipulator( + "arm", + 7, adapter_type="xarm" if global_config.xarm7_ip else "mock", address=global_config.xarm7_ip, - add_gripper=True, + gripper=True, ) xarm7_planner_coordinator = autoconnect( ManipulationModule.blueprint( - robots=[_xarm7_cfg.to_robot_model_config()], + robots=[_make_xarm7_model_config(name="arm", add_gripper=True)], planning_timeout=10.0, visualization={"backend": "meshcat"}, ), @@ -125,32 +225,30 @@ tick_rate=100.0, publish_joint_state=True, joint_state_frame_id="coordinator", - hardware=[_xarm7_cfg.to_hardware_component()], - tasks=[_xarm7_cfg.to_task_config()], + hardware=[_xarm7_hw], + tasks=[_trajectory_task("arm", _xarm7_hw.joints)], ), ) # Dual XArm7 mock planner + coordinator for bimanual planning demos. # Usage: dimos run dual-xarm7-planner-coordinator -_left_xarm7_cfg = _catalog_xarm7( - name="left_arm", +_left_xarm7_hw = manipulator( + "left_arm", + 7, adapter_type="mock", - add_gripper=False, - y_offset=0.5, ) -_right_xarm7_cfg = _catalog_xarm7( - name="right_arm", +_right_xarm7_hw = manipulator( + "right_arm", + 7, adapter_type="mock", - add_gripper=False, - y_offset=-0.5, ) dual_xarm7_planner_coordinator = autoconnect( ManipulationModule.blueprint( robots=[ - _left_xarm7_cfg.to_robot_model_config(), - _right_xarm7_cfg.to_robot_model_config(), + _make_xarm7_model_config(name="left_arm", add_gripper=False, y_offset=0.5), + _make_xarm7_model_config(name="right_arm", add_gripper=False, y_offset=-0.5), ], planning_timeout=10.0, visualization={"backend": "viser", "allow_plan_execute": True}, @@ -160,12 +258,12 @@ publish_joint_state=True, joint_state_frame_id="coordinator", hardware=[ - _left_xarm7_cfg.to_hardware_component(), - _right_xarm7_cfg.to_hardware_component(), + _left_xarm7_hw, + _right_xarm7_hw, ], tasks=[ - _left_xarm7_cfg.to_task_config(), - _right_xarm7_cfg.to_task_config(), + _trajectory_task("left_arm", _left_xarm7_hw.joints), + _trajectory_task("right_arm", _right_xarm7_hw.joints), ], ), ).transports( @@ -221,18 +319,16 @@ rotation=Quaternion(0.70513398, 0.00535696, 0.70897578, -0.01052180), # xyzw ) -_xarm7_perception_cfg = _catalog_xarm7( - name="arm", - adapter_type="xarm" if global_config.xarm7_ip else "mock", - address=global_config.xarm7_ip, - pitch=math.radians(45), - add_gripper=True, - tf_extra_links=["link7"], -) - xarm_perception = autoconnect( PickAndPlaceModule.blueprint( - robots=[_xarm7_perception_cfg.to_robot_model_config()], + robots=[ + _make_xarm7_model_config( + name="arm", + add_gripper=True, + pitch=math.radians(45), + tf_extra_links=["link7"], + ) + ], planning_timeout=10.0, visualization={"backend": "meshcat"}, floor_z=-0.02, @@ -331,25 +427,28 @@ # Sim perception: MujocoSimModule owns the MujocoEngine and publishes both # camera streams and joint state via shared memory. # ShmMujocoAdapter attaches to the same SHM buffers by MJCF path. - -from dimos.robot.catalog.ufactory import XARM7_SIM_PATH -from dimos.simulation.engines.mujoco_sim_module import MujocoSimModule -from dimos.visualization.rerun.bridge import RerunBridgeModule - -_xarm7_sim_cfg = _catalog_xarm7( - name="arm", +_xarm7_sim_home = [0.0, 0.0, 0.0, 0.0, 0.0, -0.7, 0.0] +_xarm7_sim_hw = manipulator( + "arm", + 7, adapter_type="sim_mujoco", address=str(XARM7_SIM_PATH), - add_gripper=True, - pitch=math.radians(45), - tf_extra_links=["link7"], - home_joints=[0.0, 0.0, 0.0, 0.0, 0.0, -0.7, 0.0], - pre_grasp_offset=0.05, + gripper=True, + home_joints=_xarm7_sim_home, ) xarm_perception_sim = autoconnect( PickAndPlaceModule.blueprint( - robots=[_xarm7_sim_cfg.to_robot_model_config()], + robots=[ + _make_xarm7_model_config( + name="arm", + add_gripper=True, + pitch=math.radians(45), + tf_extra_links=["link7"], + home_joints=_xarm7_sim_home, + pre_grasp_offset=0.05, + ) + ], planning_timeout=10.0, visualization={"backend": "meshcat"}, ), @@ -365,8 +464,8 @@ tick_rate=100.0, publish_joint_state=True, joint_state_frame_id="coordinator", - hardware=[_xarm7_sim_cfg.to_hardware_component()], - tasks=[_xarm7_sim_cfg.to_task_config()], + hardware=[_xarm7_sim_hw], + tasks=[_trajectory_task("arm", _xarm7_sim_hw.joints)], ), RerunBridgeModule.blueprint(), ) diff --git a/dimos/robot/catalog/test_ufactory.py b/dimos/robot/catalog/test_ufactory.py deleted file mode 100644 index 3875ff9c92..0000000000 --- a/dimos/robot/catalog/test_ufactory.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -from collections.abc import Callable -import math - -import pytest - -from dimos.robot.catalog.ufactory import xarm6, xarm7 -from dimos.robot.config import RobotConfig - - -@pytest.mark.parametrize("factory", [xarm6, xarm7]) -def test_xarm_instance_offsets_are_encoded_only_in_base_pose( - factory: Callable[..., RobotConfig], -) -> None: - config = factory(name="arm", y_offset=0.5, pitch=0.25) - model_config = config.to_robot_model_config() - - assert model_config.xacro_args["attach_xyz"] == "0 0 0" - assert model_config.xacro_args["attach_rpy"] == "0 0 0" - assert model_config.base_pose.position.y == 0.5 - assert model_config.base_pose.orientation.x == 0.0 - assert model_config.base_pose.orientation.y == pytest.approx(math.sin(0.125)) - assert model_config.base_pose.orientation.z == 0.0 - assert model_config.base_pose.orientation.w == pytest.approx(math.cos(0.125)) - assert model_config.strip_model_world_joint is True diff --git a/dimos/robot/catalog/ufactory.py b/dimos/robot/catalog/ufactory.py deleted file mode 100644 index 9c21585d2a..0000000000 --- a/dimos/robot/catalog/ufactory.py +++ /dev/null @@ -1,174 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""UFactory xArm robot configurations.""" - -from __future__ import annotations - -import math -from typing import Any - -from dimos.robot.config import GripperConfig, RobotConfig -from dimos.utils.data import LfsPath - -# Pre-built URDFs for Pinocchio FK (xacro not supported by Pinocchio) -XARM6_FK_MODEL = LfsPath("xarm_description/urdf/xarm6/xarm6.urdf") -XARM7_FK_MODEL = LfsPath("xarm_description/urdf/xarm7/xarm7.urdf") - -# Simulation model paths (MJCF) -XARM7_SIM_PATH = LfsPath("xarm7/scene.xml") -XARM6_SIM_PATH = LfsPath("xarm6/scene.xml") - -# XArm gripper collision exclusions (parallel linkage mechanism) -# The gripper uses mimic joints where non-adjacent links can overlap legitimately -XARM_GRIPPER_COLLISION_EXCLUSIONS: list[tuple[str, str]] = [ - ("right_inner_knuckle", "right_outer_knuckle"), - ("left_inner_knuckle", "left_outer_knuckle"), - ("right_inner_knuckle", "right_finger"), - ("left_inner_knuckle", "left_finger"), - ("left_finger", "right_finger"), - ("left_outer_knuckle", "right_outer_knuckle"), - ("left_inner_knuckle", "right_inner_knuckle"), - ("left_outer_knuckle", "right_finger"), - ("right_outer_knuckle", "left_finger"), - ("xarm_gripper_base_link", "left_inner_knuckle"), - ("xarm_gripper_base_link", "right_inner_knuckle"), - ("xarm_gripper_base_link", "left_finger"), - ("xarm_gripper_base_link", "right_finger"), - ("link6", "xarm_gripper_base_link"), - ("link6", "left_outer_knuckle"), - ("link6", "right_outer_knuckle"), -] - - -def _base_pose_from_offsets( - x_offset: float, y_offset: float, z_offset: float, pitch: float -) -> list[float]: - half_pitch = pitch / 2.0 - return [x_offset, y_offset, z_offset, 0.0, math.sin(half_pitch), 0.0, math.cos(half_pitch)] - - -def xarm7( - name: str = "arm", - *, - adapter_type: str = "mock", - address: str | None = None, - add_gripper: bool = False, - x_offset: float = 0.0, - y_offset: float = 0.0, - z_offset: float = 0.0, - pitch: float = 0.0, - tf_extra_links: list[str] | None = None, - **overrides: Any, -) -> RobotConfig: - """Create an xArm7 robot configuration.""" - xacro_args: dict[str, str] = { - "dof": "7", - "limited": "true", - "attach_xyz": "0 0 0", - "attach_rpy": "0 0 0", - } - if add_gripper: - xacro_args["add_gripper"] = "true" - - defaults: dict[str, Any] = { - "name": name, - "model_path": LfsPath("xarm_description") / "urdf/xarm_device.urdf.xacro", - "end_effector_link": "link_tcp" if add_gripper else "link7", - "adapter_type": adapter_type, - "address": address, - "joint_names": [f"joint{i}" for i in range(1, 8)], - "base_link": "link_base", - "home_joints": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - "base_pose": _base_pose_from_offsets(x_offset, y_offset, z_offset, pitch), - "strip_model_world_joint": True, - "package_paths": {"xarm_description": LfsPath("xarm_description")}, - "xacro_args": xacro_args, - "auto_convert_meshes": True, - "collision_exclusion_pairs": XARM_GRIPPER_COLLISION_EXCLUSIONS if add_gripper else [], - "tf_extra_links": tf_extra_links or [], - "gripper": GripperConfig( - type="xarm", - joints=["gripper"], - collision_exclusions=XARM_GRIPPER_COLLISION_EXCLUSIONS, - open_position=0.85, - close_position=0.0, - ) - if add_gripper - else None, - } - defaults.update(overrides) - return RobotConfig(**defaults) - - -def xarm6( - name: str = "arm", - *, - adapter_type: str = "mock", - address: str | None = None, - add_gripper: bool = True, - x_offset: float = 0.0, - y_offset: float = 0.0, - z_offset: float = 0.0, - pitch: float = 0.0, - tf_extra_links: list[str] | None = None, - **overrides: Any, -) -> RobotConfig: - """Create an xArm6 robot configuration.""" - xacro_args: dict[str, str] = { - "dof": "6", - "limited": "true", - "attach_xyz": "0 0 0", - "attach_rpy": "0 0 0", - } - if add_gripper: - xacro_args["add_gripper"] = "true" - - defaults: dict[str, Any] = { - "name": name, - "model_path": LfsPath("xarm_description") / "urdf/xarm_device.urdf.xacro", - "end_effector_link": "link_tcp" if add_gripper else "link6", - "adapter_type": adapter_type, - "address": address, - "joint_names": [f"joint{i}" for i in range(1, 7)], - "base_link": "link_base", - "home_joints": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - "base_pose": _base_pose_from_offsets(x_offset, y_offset, z_offset, pitch), - "strip_model_world_joint": True, - "package_paths": {"xarm_description": LfsPath("xarm_description")}, - "xacro_args": xacro_args, - "auto_convert_meshes": True, - "collision_exclusion_pairs": XARM_GRIPPER_COLLISION_EXCLUSIONS if add_gripper else [], - "tf_extra_links": tf_extra_links or [], - "gripper": GripperConfig( - type="xarm", - joints=["gripper"], - collision_exclusions=XARM_GRIPPER_COLLISION_EXCLUSIONS, - open_position=0.85, - close_position=0.0, - ) - if add_gripper - else None, - } - defaults.update(overrides) - return RobotConfig(**defaults) - - -__all__ = [ - "XARM6_FK_MODEL", - "XARM7_FK_MODEL", - "XARM_GRIPPER_COLLISION_EXCLUSIONS", - "xarm6", - "xarm7", -] diff --git a/dimos/robot/manipulators/xarm/blueprints.py b/dimos/robot/manipulators/xarm/blueprints.py index 4bed0259da..43e7f4694b 100644 --- a/dimos/robot/manipulators/xarm/blueprints.py +++ b/dimos/robot/manipulators/xarm/blueprints.py @@ -23,27 +23,58 @@ dimos run keyboard-teleop-xarm7 """ -from dimos.control.coordinator import ControlCoordinator +from pathlib import Path + +from dimos.control.blueprints._hardware import XARM6_FK_MODEL, XARM7_FK_MODEL, manipulator +from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import autoconnect from dimos.core.global_config import global_config from dimos.manipulation.manipulation_module import ManipulationModule -from dimos.robot.catalog.ufactory import ( - XARM6_FK_MODEL, - XARM7_FK_MODEL, - xarm6 as _catalog_xarm6, - xarm7 as _catalog_xarm7, -) +from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule +from dimos.utils.data import LfsPath + +_XARM_MODEL_PATH = LfsPath("xarm_description") / "urdf/xarm_device.urdf.xacro" +_XARM_PACKAGE_PATHS: dict[str, Path] = {"xarm_description": LfsPath("xarm_description")} + + +def _xarm_model_config(dof: int) -> RobotModelConfig: + return RobotModelConfig( + name="arm", + model_path=_XARM_MODEL_PATH, + base_pose=PoseStamped( + position=Vector3(x=0.0, y=0.0, z=0.0), + orientation=Quaternion(0.0, 0.0, 0.0, 1.0), + ), + strip_model_world_joint=True, + joint_names=[f"joint{i}" for i in range(1, dof + 1)], + end_effector_link=f"link{dof}", + base_link="link_base", + package_paths=_XARM_PACKAGE_PATHS, + xacro_args={ + "dof": str(dof), + "limited": "true", + "attach_xyz": "0 0 0", + "attach_rpy": "0 0 0", + }, + auto_convert_meshes=True, + coordinator_task_name="traj_arm", + home_joints=[0.0] * dof, + ) + -_xarm6_cfg = _catalog_xarm6( - name="arm", - add_gripper=False, +_xarm6_hw = manipulator( + "arm", + 6, adapter_type="xarm" if global_config.xarm6_ip else "mock", address=global_config.xarm6_ip or None, ) -_xarm7_cfg = _catalog_xarm7( - name="arm", - add_gripper=False, +_xarm7_hw = manipulator( + "arm", + 7, adapter_type="xarm" if global_config.xarm7_ip else "mock", address=global_config.xarm7_ip or None, ) @@ -52,25 +83,26 @@ keyboard_teleop_xarm6 = autoconnect( KeyboardTeleopModule.blueprint( model_path=XARM6_FK_MODEL, - ee_joint_id=_xarm6_cfg.dof, - joint_names=_xarm6_cfg.global_joint_names, + ee_joint_id=6, + joint_names=_xarm6_hw.joints, ), ControlCoordinator.blueprint( tick_rate=100.0, publish_joint_state=True, joint_state_frame_id="coordinator", - hardware=[_xarm6_cfg.to_hardware_component()], + hardware=[_xarm6_hw], tasks=[ - _xarm6_cfg.to_task_config( - task_type="cartesian_ik", - task_name="cartesian_ik_arm", - model_path=XARM6_FK_MODEL, - ee_joint_id=_xarm6_cfg.dof, + TaskConfig( + name="cartesian_ik_arm", + type="cartesian_ik", + joint_names=_xarm6_hw.joints, + priority=10, + params={"model_path": XARM6_FK_MODEL, "ee_joint_id": 6}, ), ], ), ManipulationModule.blueprint( - robots=[_xarm6_cfg.to_robot_model_config()], + robots=[_xarm_model_config(6)], visualization={"backend": "meshcat"}, ), ) @@ -79,25 +111,26 @@ keyboard_teleop_xarm7 = autoconnect( KeyboardTeleopModule.blueprint( model_path=XARM7_FK_MODEL, - ee_joint_id=_xarm7_cfg.dof, - joint_names=_xarm7_cfg.global_joint_names, + ee_joint_id=7, + joint_names=_xarm7_hw.joints, ), ControlCoordinator.blueprint( tick_rate=100.0, publish_joint_state=True, joint_state_frame_id="coordinator", - hardware=[_xarm7_cfg.to_hardware_component()], + hardware=[_xarm7_hw], tasks=[ - _xarm7_cfg.to_task_config( - task_type="cartesian_ik", - task_name="cartesian_ik_arm", - model_path=XARM7_FK_MODEL, - ee_joint_id=_xarm7_cfg.dof, + TaskConfig( + name="cartesian_ik_arm", + type="cartesian_ik", + joint_names=_xarm7_hw.joints, + priority=10, + params={"model_path": XARM7_FK_MODEL, "ee_joint_id": 7}, ), ], ), ManipulationModule.blueprint( - robots=[_xarm7_cfg.to_robot_model_config()], + robots=[_xarm_model_config(7)], visualization={"backend": "meshcat"}, ), ) From eb782302c975ccaa162067cd6802f3a945a5c033 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 21:18:38 -0700 Subject: [PATCH 070/110] fix: address movegroup ci failures --- dimos/control/blueprints/dual.py | 7 --- dimos/manipulation/blueprints.py | 13 ----- dimos/manipulation/manipulation_module.py | 25 +++++----- .../planning/groups/identifiers.py | 14 ------ .../kinematics/drake_optimization_ik.py | 47 +++++++++++++++++++ .../planning/kinematics/pink_ik.py | 9 ++-- .../planning/planning_identifiers.py | 15 +----- .../planning/world/drake_world.py | 1 + dimos/manipulation/visualization/types.py | 2 +- dimos/manipulation/visualization/viser/gui.py | 19 ++++++-- dimos/robot/config.py | 6 --- dimos/robot/manipulators/xarm/blueprints.py | 2 - 12 files changed, 79 insertions(+), 81 deletions(-) diff --git a/dimos/control/blueprints/dual.py b/dimos/control/blueprints/dual.py index 3444f23335..29ee797c0d 100644 --- a/dimos/control/blueprints/dual.py +++ b/dimos/control/blueprints/dual.py @@ -98,10 +98,3 @@ ), ], ) - - -__all__ = [ - "coordinator_dual_mock", - "coordinator_dual_xarm", - "coordinator_piper_xarm", -] diff --git a/dimos/manipulation/blueprints.py b/dimos/manipulation/blueprints.py index 6bdb148dfb..5d6d215025 100644 --- a/dimos/manipulation/blueprints.py +++ b/dimos/manipulation/blueprints.py @@ -476,16 +476,3 @@ def _trajectory_task(name: str, joint_names: list[str]) -> TaskConfig: McpServer.blueprint(), McpClient.blueprint(system_prompt=_MANIPULATION_AGENT_SYSTEM_PROMPT), ) - - -__all__ = [ - "dual_xarm6_planner_coordinator", - "dual_xarm7_planner_coordinator", - "xarm6_planner_only", - "xarm7_planner_coordinator", - "xarm7_planner_coordinator_agent", - "xarm_perception", - "xarm_perception_agent", - "xarm_perception_sim", - "xarm_perception_sim_agent", -] diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 76328eb887..6e67bd95e4 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -364,6 +364,7 @@ def _tf_publish_loop(self) -> None: # backend's full robot TF tree, once consumers stop assuming a # single robot-scoped end-effector frame. target_frame = config.end_effector_link + ee_pose: PoseStamped | None pose_group_id = self._primary_pose_group_id_for_robot(config.name) if pose_group_id is not None: pose_group = self._world_monitor.planning_groups.get(pose_group_id) @@ -1016,7 +1017,7 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> RobotInfo | Non else [] ) - return { + info: RobotInfo = { "name": config.name, "world_robot_id": robot_id, "joint_names": config.joint_names, @@ -1032,6 +1033,7 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> RobotInfo | Non if (init := self._init_joints.get(robot_name)) else None, } + return info def robot_items(self) -> list[tuple[RobotName, WorldRobotID, RobotModelConfig]]: """Return configured robots for in-process visualization adapters.""" @@ -1207,13 +1209,14 @@ def execute_plan(self, plan: GeneratedPlan | None = None) -> bool: ) assert self._world_monitor is not None - dispatches: list[tuple[RobotName, RobotModelConfig, JointTrajectory]] = [] + dispatches: list[tuple[RobotName, str, RobotModelConfig, JointTrajectory]] = [] for name in affected: robot = self._get_robot(name) if robot is None: return False resolved_name, robot_id, config, traj_gen = robot - if not config.coordinator_task_name: + task_name = config.coordinator_task_name + if not task_name: logger.error(f"No coordinator_task_name for '{resolved_name}'") return False @@ -1266,31 +1269,27 @@ def execute_plan(self, plan: GeneratedPlan | None = None) -> bool: points=local_trajectory.points, timestamp=local_trajectory.timestamp, ) - dispatches.append((resolved_name, config, trajectory)) + dispatches.append((resolved_name, task_name, config, trajectory)) self._state = ManipulationState.EXECUTING - for _name, config, trajectory in dispatches: + for _name, task_name, config, trajectory in dispatches: logger.info( "Executing: task='%s', %d pts, %.2fs", - config.coordinator_task_name, + task_name, len(trajectory.points), trajectory.duration, ) try: result = self._invoke_coordinator_task( client, - config.coordinator_task_name, + task_name, "execute", {"trajectory": trajectory}, ) except TimeoutError as exc: - return self._fail( - f"Coordinator RPC timed out for task '{config.coordinator_task_name}': {exc}" - ) + return self._fail(f"Coordinator RPC timed out for task '{task_name}': {exc}") except Exception as exc: - return self._fail( - f"Coordinator RPC failed for task '{config.coordinator_task_name}': {exc}" - ) + return self._fail(f"Coordinator RPC failed for task '{task_name}': {exc}") logger.info( "Coordinator execute result: task='%s', result=%r", config.coordinator_task_name, diff --git a/dimos/manipulation/planning/groups/identifiers.py b/dimos/manipulation/planning/groups/identifiers.py index f6232d0eba..db9d990081 100644 --- a/dimos/manipulation/planning/groups/identifiers.py +++ b/dimos/manipulation/planning/groups/identifiers.py @@ -110,17 +110,3 @@ def local_joint_name_from_global( except ValueError as exc: raise ValueError(f"Invalid global joint name: {global_joint_name!r}") from exc return local_name - - -__all__ = [ - "assert_global_joint_names", - "assert_local_joint_names", - "assert_valid_local_joint_name", - "assert_valid_robot_name", - "is_global_joint_name", - "local_joint_name_from_global", - "make_global_joint_name", - "make_global_joint_names", - "make_planning_group_id", - "parse_planning_group_id", -] diff --git a/dimos/manipulation/planning/kinematics/drake_optimization_ik.py b/dimos/manipulation/planning/kinematics/drake_optimization_ik.py index 76aa615066..5e9679e656 100644 --- a/dimos/manipulation/planning/kinematics/drake_optimization_ik.py +++ b/dimos/manipulation/planning/kinematics/drake_optimization_ik.py @@ -16,10 +16,12 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING import numpy as np +from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import IKResult, WorldRobotID from dimos.manipulation.planning.spec.protocols import WorldSpec @@ -150,6 +152,44 @@ def solve( f"IK failed after {max_attempts} attempts", ) + def solve_pose_targets( + self, + world: WorldSpec, + pose_targets: Mapping[PlanningGroup, PoseStamped], + auxiliary_groups: Sequence[PlanningGroup] = (), + seed: JointState | None = None, + position_tolerance: float = 0.001, + orientation_tolerance: float = 0.01, + max_attempts: int = 10, + ) -> IKResult: + """Solve a single planning-group pose target for protocol compatibility.""" + if auxiliary_groups: + return _create_failure_result( + IKStatus.NO_SOLUTION, + "DrakeOptimizationIK does not support auxiliary planning groups", + ) + if len(pose_targets) != 1: + return _create_failure_result( + IKStatus.NO_SOLUTION, + "DrakeOptimizationIK supports exactly one pose target", + ) + group, target_pose = next(iter(pose_targets.items())) + robot_id = _robot_id_for_name(world, group.robot_name) + if robot_id is None: + return _create_failure_result( + IKStatus.NO_SOLUTION, + f"No robot named '{group.robot_name}'", + ) + return self.solve( + world=world, + robot_id=robot_id, + target_pose=target_pose, + seed=seed, + position_tolerance=position_tolerance, + orientation_tolerance=orientation_tolerance, + max_attempts=max_attempts, + ) + def _solve_single( self, world: WorldSpec, @@ -253,6 +293,13 @@ def _create_success_result( ) +def _robot_id_for_name(world: WorldSpec, robot_name: str) -> WorldRobotID | None: + for robot_id in world.get_robot_ids(): + if world.get_robot_config(robot_id).name == robot_name: + return robot_id + return None + + def _create_failure_result( status: IKStatus, message: str, diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index 3397fb20b9..9caa7846a6 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -47,9 +47,9 @@ from numpy.typing import NDArray try: - import pink - import pinocchio - import qpsolvers + import pink # type: ignore[import-not-found, import-untyped] + import pinocchio # type: ignore[import-not-found] + import qpsolvers # type: ignore[import-not-found] except ImportError as exc: pink = None # type: ignore[assignment] pinocchio = None # type: ignore[assignment] @@ -817,6 +817,3 @@ def _single_pose_group_for_robot(world: WorldSpec, robot_name: RobotName) -> Pla f"Robot '{robot_name}' has {len(pose_groups)} pose-targetable planning groups" ) return pose_groups[0] - - -__all__ = ["PinkIK", "PinkIKConfig", "PinkIKDependencyError"] diff --git a/dimos/manipulation/planning/planning_identifiers.py b/dimos/manipulation/planning/planning_identifiers.py index 5e4c87a549..1acd15e824 100644 --- a/dimos/manipulation/planning/planning_identifiers.py +++ b/dimos/manipulation/planning/planning_identifiers.py @@ -17,7 +17,7 @@ New code should import from ``dimos.manipulation.planning.groups.identifiers``. """ -from dimos.manipulation.planning.groups.identifiers import ( +from dimos.manipulation.planning.groups.identifiers import ( # noqa: F401 assert_global_joint_names, assert_local_joint_names, assert_valid_local_joint_name, @@ -29,16 +29,3 @@ make_planning_group_id, parse_planning_group_id, ) - -__all__ = [ - "assert_global_joint_names", - "assert_local_joint_names", - "assert_valid_local_joint_name", - "assert_valid_robot_name", - "is_global_joint_name", - "local_joint_name_from_global", - "make_global_joint_name", - "make_global_joint_names", - "make_planning_group_id", - "parse_planning_group_id", -] diff --git a/dimos/manipulation/planning/world/drake_world.py b/dimos/manipulation/planning/world/drake_world.py index 05ca3b268b..daa0a25158 100644 --- a/dimos/manipulation/planning/world/drake_world.py +++ b/dimos/manipulation/planning/world/drake_world.py @@ -36,6 +36,7 @@ GeneratedPlan, Obstacle, PlanningGroupID, + PlanningSceneInfo, WorldRobotID, ) from dimos.manipulation.planning.spec.protocols import VisualizationSpec, WorldSpec diff --git a/dimos/manipulation/visualization/types.py b/dimos/manipulation/visualization/types.py index eb69e81768..8122643ecd 100644 --- a/dimos/manipulation/visualization/types.py +++ b/dimos/manipulation/visualization/types.py @@ -51,7 +51,7 @@ class RobotInfo(TypedDict, total=False): name: RobotName world_robot_id: WorldRobotID joint_names: list[str] - end_effector_link: str + end_effector_link: str | None base_link: str max_velocity: float max_acceleration: float diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 22a6de12ff..175d900238 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -14,7 +14,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, TypeAlias, cast from dimos.manipulation.planning.groups.models import PlanningGroup @@ -361,7 +361,10 @@ def _ensure_scene_controls(self) -> None: continue ee_control = self.scene.ensure_target_controls( group_id, - lambda target, gid=group_id: self._on_transform_update(gid, target), + cast( + "Callable[[TransformControlsHandle], None]", + lambda target, gid=group_id: self._on_transform_update(gid, target), + ), ) if ee_control is not None: self._handles[handle_key] = ee_control @@ -380,7 +383,8 @@ def _build_joint_sliders(self) -> None: return joint_folder = self._handles.get("joint_control_folder") if joint_folder is not None: - with joint_folder: + folder = cast("GuiFolderHandle", joint_folder) + with folder: self._build_joint_slider_handles(gui) return self._build_joint_slider_handles(gui) @@ -502,7 +506,12 @@ def _sync_group_selector(self, groups: list[PlanningGroup]) -> None: ), hint="Click to toggle this planning group in the target set.", ) - handle.on_click(lambda _event, gid=group_id: self._toggle_group_selected(gid)) + handle.on_click( + cast( + "Callable[[object], None]", + lambda _event, gid=group_id: self._toggle_group_selected(gid), + ) + ) self._handles[key] = handle else: self._set_optional_handle_attr(handle, "label", label) @@ -783,7 +792,7 @@ def _on_transform_update( return if self._suppress_target_callbacks or group_id not in self.state.selected_group_ids: return - pose = pose_from_transform_values(target.position, target.wxyz) + pose = pose_from_transform_values(target.position.tolist(), target.wxyz.tolist()) self.state.cartesian_target = pose self.state.pose_targets[group_id] = pose sequence_id = self.state.next_sequence_id() diff --git a/dimos/robot/config.py b/dimos/robot/config.py index 3b52684bb0..42da6d56ae 100644 --- a/dimos/robot/config.py +++ b/dimos/robot/config.py @@ -285,9 +285,3 @@ def to_task_config( auto_start=auto_start, params=params, ) - - -__all__ = [ - "GripperConfig", - "RobotConfig", -] diff --git a/dimos/robot/manipulators/xarm/blueprints.py b/dimos/robot/manipulators/xarm/blueprints.py index 43e7f4694b..af4b6d92d0 100644 --- a/dimos/robot/manipulators/xarm/blueprints.py +++ b/dimos/robot/manipulators/xarm/blueprints.py @@ -134,5 +134,3 @@ def _xarm_model_config(dof: int) -> RobotModelConfig: visualization={"backend": "meshcat"}, ), ) - -__all__ = ["keyboard_teleop_xarm6", "keyboard_teleop_xarm7"] From 8a9e1df2b8bd837daeec533808096d949a03ca1a Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 21:30:02 -0700 Subject: [PATCH 071/110] test: skip dual blueprint checks without lfs data --- dimos/control/blueprints/test_dual.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/dimos/control/blueprints/test_dual.py b/dimos/control/blueprints/test_dual.py index 4a788090b3..082b6ec8a0 100644 --- a/dimos/control/blueprints/test_dual.py +++ b/dimos/control/blueprints/test_dual.py @@ -14,13 +14,25 @@ """Tests for dual-arm control blueprints.""" +import pytest + from dimos.control.blueprints.dual import coordinator_dual_xarm from dimos.control.coordinator import ControlCoordinator -from dimos.manipulation.blueprints import dual_xarm6_planner_coordinator +from dimos.core.coordination.blueprints import Blueprint from dimos.manipulation.manipulation_module import ManipulationModule from dimos.manipulation.planning.groups.identifiers import make_global_joint_names +def _dual_xarm6_planner_coordinator() -> Blueprint: + try: + from dimos.manipulation.blueprints import dual_xarm6_planner_coordinator + except RuntimeError as exc: + if "Failed to pull LFS file" in str(exc): + pytest.skip(f"xArm LFS data unavailable: {exc}") + raise + return dual_xarm6_planner_coordinator + + def _coordinator_task_names(blueprint) -> list[str]: atom = next(atom for atom in blueprint.blueprints if atom.module is ControlCoordinator) return [task.name for task in atom.kwargs["tasks"]] @@ -42,6 +54,7 @@ def _manipulation_visualization(blueprint): def test_dual_xarm6_integrated_blueprint_has_planner_and_coordinator() -> None: + dual_xarm6_planner_coordinator = _dual_xarm6_planner_coordinator() modules = [atom.module for atom in dual_xarm6_planner_coordinator.blueprints] assert ManipulationModule in modules @@ -49,12 +62,14 @@ def test_dual_xarm6_integrated_blueprint_has_planner_and_coordinator() -> None: def test_dual_xarm6_integrated_blueprint_uses_viser_for_execution_ui() -> None: + dual_xarm6_planner_coordinator = _dual_xarm6_planner_coordinator() visualization = _manipulation_visualization(dual_xarm6_planner_coordinator) assert visualization == {"backend": "viser", "allow_plan_execute": True} def test_dual_xarm6_integrated_tasks_match_planner_robots() -> None: + dual_xarm6_planner_coordinator = _dual_xarm6_planner_coordinator() tasks_by_name = {task.name: task for task in _coordinator_tasks(dual_xarm6_planner_coordinator)} for robot in _manipulation_robots(dual_xarm6_planner_coordinator): From 39efbca7eaccec90c6809ba047d843d67d819586 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 22:07:59 -0700 Subject: [PATCH 072/110] fix: align openarm planner coordinator joint state --- .../test_manipulation_planning_groups.py | 8 ++- .../robot/manipulators/openarm/blueprints.py | 53 ++++++++++++++++--- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/dimos/e2e_tests/test_manipulation_planning_groups.py b/dimos/e2e_tests/test_manipulation_planning_groups.py index 24b526e24e..7ccdf3f4ce 100644 --- a/dimos/e2e_tests/test_manipulation_planning_groups.py +++ b/dimos/e2e_tests/test_manipulation_planning_groups.py @@ -32,6 +32,7 @@ from dimos.e2e_tests.dimos_cli_call import DimosCliCall from dimos.e2e_tests.lcm_spy import LcmSpy from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.msgs.sensor_msgs.JointState import JointState from dimos.msgs.trajectory_msgs.TrajectoryStatus import TrajectoryState @@ -126,7 +127,10 @@ def _prepare_for_planning(client: RPCClient, robot_names: tuple[str, ...]) -> No def _planning_group_id(info: dict[str, Any]) -> str: groups = info["planning_groups"] assert len(groups) == 1 - group_id = groups[0]["id"] + group = groups[0] + if isinstance(group, PlanningGroup): + return group.id + group_id = group["id"] assert isinstance(group_id, str) return group_id @@ -166,7 +170,7 @@ def test_single_arm_plans_and_executes_through_control_coordinator( planned = client.plan_to_joint_targets({left_id: _offset_target(client, "left_arm", 0.02)}) assert planned, client.get_error() assert client.has_planned_path() - assert client.execute_plan(robot_name="left_arm") + assert client.execute_plan() _wait_for_trajectory_completion(client, "left_arm") finally: diff --git a/dimos/robot/manipulators/openarm/blueprints.py b/dimos/robot/manipulators/openarm/blueprints.py index 84a8a0489a..ebdd589084 100644 --- a/dimos/robot/manipulators/openarm/blueprints.py +++ b/dimos/robot/manipulators/openarm/blueprints.py @@ -22,11 +22,13 @@ from dimos.control.components import HardwareComponent, HardwareType from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import autoconnect +from dimos.core.transport import LCMTransport from dimos.manipulation.manipulation_module import ManipulationModule from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.JointState import JointState from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule from dimos.utils.data import LfsPath @@ -66,15 +68,22 @@ def _openarm_hardware( adapter_type: str = "mock", address: str | None = None, adapter_kwargs: dict[str, Any] | None = None, + scoped_joints: bool = False, ) -> HardwareComponent: _validate_side(side) + resolved_name = name or f"{side}_arm" + local_joints = _openarm_joints(side) kwargs = {"side": side} if adapter_kwargs: kwargs.update(adapter_kwargs) return HardwareComponent( - hardware_id=name or f"{side}_arm", + hardware_id=resolved_name, hardware_type=HardwareType.MANIPULATOR, - joints=_openarm_joints(side), + joints=( + [f"{resolved_name}/{joint}" for joint in local_joints] + if scoped_joints + else local_joints + ), adapter_type=adapter_type, address=address, adapter_kwargs=kwargs, @@ -143,6 +152,8 @@ def _openarm_single_model_config() -> RobotModelConfig: # Mock bimanual: no hardware, great for verifying wiring. _mock_left = _openarm_hardware(side="left") _mock_right = _openarm_hardware(side="right") +_mock_planner_left = _openarm_hardware(side="left", scoped_joints=True) +_mock_planner_right = _openarm_hardware(side="right", scoped_joints=True) coordinator_openarm_mock = ControlCoordinator.blueprint( hardware=[_mock_left, _mock_right], @@ -175,6 +186,20 @@ def _openarm_single_model_config() -> RobotModelConfig: adapter_type="openarm", adapter_kwargs=_ADAPTER_KWARGS, ) +_planner_left_hw = _openarm_hardware( + side="left", + address=LEFT_CAN, + adapter_type="openarm", + adapter_kwargs=_ADAPTER_KWARGS, + scoped_joints=True, +) +_planner_right_hw = _openarm_hardware( + side="right", + address=RIGHT_CAN, + adapter_type="openarm", + adapter_kwargs=_ADAPTER_KWARGS, + scoped_joints=True, +) coordinator_openarm_left = ControlCoordinator.blueprint( hardware=[_left_hw], @@ -206,12 +231,18 @@ def _openarm_single_model_config() -> RobotModelConfig: visualization={"backend": "meshcat"}, ), ControlCoordinator.blueprint( - hardware=[_mock_left, _mock_right], + hardware=[_mock_planner_left, _mock_planner_right], tasks=[ - _openarm_task(_mock_left), - _openarm_task(_mock_right), + _openarm_task(_mock_planner_left), + _openarm_task(_mock_planner_right), ], ), +).transports( + { + ("coordinator_joint_state", JointState): LCMTransport( + "/coordinator/joint_state", JointState + ), + } ) # Planner + coordinator (real hw): plan and execute on both arms. @@ -225,12 +256,18 @@ def _openarm_single_model_config() -> RobotModelConfig: visualization={"backend": "meshcat"}, ), ControlCoordinator.blueprint( - hardware=[_left_hw, _right_hw], + hardware=[_planner_left_hw, _planner_right_hw], tasks=[ - _openarm_task(_left_hw), - _openarm_task(_right_hw), + _openarm_task(_planner_left_hw), + _openarm_task(_planner_right_hw), ], ), +).transports( + { + ("coordinator_joint_state", JointState): LCMTransport( + "/coordinator/joint_state", JointState + ), + } ) From 84799d1f81d50179af7c932c6f3cb0abf3805a07 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 21 Jun 2026 22:54:04 -0700 Subject: [PATCH 073/110] perf: improve pink ik --- .../planning/kinematics/pink_ik.py | 281 ++++++++------- .../planning/kinematics/test_pink_ik.py | 325 +++++++++++++++++- 2 files changed, 473 insertions(+), 133 deletions(-) diff --git a/dimos/manipulation/planning/kinematics/pink_ik.py b/dimos/manipulation/planning/kinematics/pink_ik.py index 9caa7846a6..ef46cad981 100644 --- a/dimos/manipulation/planning/kinematics/pink_ik.py +++ b/dimos/manipulation/planning/kinematics/pink_ik.py @@ -76,6 +76,15 @@ class _JointMapping: dimos_joint_names: list[str] model_joint_names: list[str] idx_q: list[int] + idx_q_array: NDArray[np.int64] + + +@dataclass +class _PinkRobotModelContext: + model: Any + mapping: _JointMapping + neutral_q: NDArray[np.float64] + frame_ids: dict[str, int] @dataclass @@ -85,6 +94,7 @@ class _PinkRobotContext: frame_id: int frame_name: str mapping: _JointMapping + neutral_q: NDArray[np.float64] | None = None class _CurrentStateRequiredError(ValueError): @@ -115,7 +125,7 @@ def __init__( config_values.update(overrides) self.config = PinkKinematicsConfig(**config_values) _check_optional_dependencies(self.config.solver) - self._robot_contexts: dict[str, _PinkRobotContext] = {} + self._robot_model_contexts: dict[tuple[object, ...], _PinkRobotModelContext] = {} def solve( self, @@ -182,22 +192,30 @@ def solve_pose_targets( for robot_name, groups in groups_by_robot.items(): robot_id = robot_ids_by_name[robot_name] robot_pose_groups = pose_groups_by_robot.get(robot_name, []) - if robot_pose_groups: + robot_pose_targets = {group: pose_targets[group] for group in robot_pose_groups} + config = world.get_robot_config(robot_id) + seed_for_robot = _seed_for_robot_with_world_fallback(world, robot_id, seed) + if robot_pose_targets: + lower_limits, upper_limits = world.get_joint_limits(robot_id) result = self._solve_pose_targets_for_robot( world=world, robot_id=robot_id, - pose_targets={group: pose_targets[group] for group in robot_pose_groups}, - seed=_seed_for_robot_with_world_fallback(world, robot_id, seed), + pose_targets=robot_pose_targets, + seed=seed_for_robot, position_tolerance=position_tolerance, orientation_tolerance=orientation_tolerance, max_attempts=max_attempts, + config=config, + lower_limits=lower_limits, + upper_limits=upper_limits, + target_models=self._targets_in_model_frame(config, robot_pose_targets), ) if not result.is_success() or result.joint_state is None: return result else: result = IKResult( status=IKStatus.SUCCESS, - joint_state=_seed_for_robot_with_world_fallback(world, robot_id, seed), + joint_state=seed_for_robot, message="Auxiliary group retained seed state", ) joint_state = result.joint_state @@ -243,22 +261,26 @@ def _solve_pose_targets_for_robot( position_tolerance: float, orientation_tolerance: float, max_attempts: int, + config: RobotModelConfig | None = None, + lower_limits: NDArray[np.float64] | None = None, + upper_limits: NDArray[np.float64] | None = None, + target_models: Mapping[PlanningGroup, NDArray[np.float64]] | None = None, ) -> IKResult: """Solve one robot's one-or-more frame targets.""" try: contexts = [ - self._get_robot_context(world, robot_id, group.tip_link) + self._get_robot_context(world, robot_id, group.tip_link, config) for group in pose_targets if group.tip_link is not None ] except (FileNotFoundError, ImportError, ValueError) as exc: return _failure(IKStatus.NO_SOLUTION, f"Pink IK model setup failed: {exc}") - config = world.get_robot_config(robot_id) - lower_limits, upper_limits = world.get_joint_limits(robot_id) - target_models = [ - self._target_in_model_frame(config, pose) for pose in pose_targets.values() - ] + config = config or world.get_robot_config(robot_id) + if lower_limits is None or upper_limits is None: + lower_limits, upper_limits = world.get_joint_limits(robot_id) + target_models_by_group = target_models or self._targets_in_model_frame(config, pose_targets) + target_model_list = [target_models_by_group[group] for group in pose_targets] fallback_result: IKResult | None = None for attempt in range(max_attempts): @@ -267,7 +289,7 @@ def _solve_pose_targets_for_robot( if len(contexts) == 1: result = self._solve_single( robot_context=contexts[0], - target_model=target_models[0], + target_model=target_model_list[0], seed_q=q0, lower_limits=lower_limits, upper_limits=upper_limits, @@ -277,7 +299,7 @@ def _solve_pose_targets_for_robot( else: result = self._solve_multi_frame( robot_contexts=contexts, - target_models=target_models, + target_models=target_model_list, seed_q=q0, lower_limits=lower_limits, upper_limits=upper_limits, @@ -309,74 +331,14 @@ def _solve_single( position_tolerance: float, orientation_tolerance: float, ) -> IKResult: - assert pink is not None - assert pinocchio is not None - configuration = pink.Configuration(robot_context.model, robot_context.data, seed_q.copy()) - target_se3 = _matrix_to_se3(pinocchio, target_model) - - frame_task = pink.tasks.FrameTask( - robot_context.frame_name, - position_cost=self.config.position_cost, - orientation_cost=self.config.orientation_cost, - lm_damping=self.config.lm_damping, - gain=self.config.gain, - ) - frame_task.set_target(target_se3) - tasks: list[Any] = [frame_task] - - if self.config.posture_cost > 0.0: - posture_task = pink.tasks.PostureTask(cost=self.config.posture_cost) - posture_task.set_target_from_configuration(configuration) - tasks.append(posture_task) - - final_position_error = float("inf") - final_orientation_error = float("inf") - - for iteration in range(self.config.max_iterations): - current_pose = self._current_frame_matrix(robot_context, configuration.q) - final_position_error, final_orientation_error = compute_pose_error( - current_pose, target_model - ) - if ( - final_position_error <= position_tolerance - and final_orientation_error <= orientation_tolerance - ): - return _success( - robot_context.mapping.dimos_joint_names, - self._q_to_dimos_positions(robot_context, configuration.q), - final_position_error, - final_orientation_error, - iteration + 1, - ) - - velocity = pink.solve_ik( - configuration, - tasks, - self.config.dt, - solver=self.config.solver, - damping=self.config.damping, - safety_break=self.config.safety_break, - ) - configuration.integrate_inplace(velocity, self.config.dt) - - joint_positions = self._q_to_dimos_positions(robot_context, configuration.q) - if not _within_limits(joint_positions, lower_limits, upper_limits): - return IKResult( - status=IKStatus.JOINT_LIMITS, - joint_state=None, - position_error=final_position_error, - orientation_error=final_orientation_error, - iterations=iteration + 1, - message="Pink IK candidate violates DimOS joint limits", - ) - - return IKResult( - status=IKStatus.NO_SOLUTION, - joint_state=None, - position_error=final_position_error, - orientation_error=final_orientation_error, - iterations=self.config.max_iterations, - message="Pink IK did not converge within the iteration budget", + return self._solve_frame_targets( + robot_contexts=[robot_context], + target_models=[target_model], + seed_q=seed_q, + lower_limits=lower_limits, + upper_limits=upper_limits, + position_tolerance=position_tolerance, + orientation_tolerance=orientation_tolerance, ) def _solve_multi_frame( @@ -390,6 +352,27 @@ def _solve_multi_frame( orientation_tolerance: float, ) -> IKResult: """Solve multiple frame tasks for one robot model.""" + return self._solve_frame_targets( + robot_contexts=robot_contexts, + target_models=target_models, + seed_q=seed_q, + lower_limits=lower_limits, + upper_limits=upper_limits, + position_tolerance=position_tolerance, + orientation_tolerance=orientation_tolerance, + ) + + def _solve_frame_targets( + self, + robot_contexts: Sequence[_PinkRobotContext], + target_models: Sequence[NDArray[np.float64]], + seed_q: NDArray[np.float64], + lower_limits: NDArray[np.float64], + upper_limits: NDArray[np.float64], + position_tolerance: float, + orientation_tolerance: float, + ) -> IKResult: + """Solve one robot model against one or more frame targets.""" assert pink is not None assert pinocchio is not None primary_context = robot_contexts[0] @@ -419,8 +402,8 @@ def _solve_multi_frame( for iteration in range(self.config.max_iterations): position_errors: list[float] = [] orientation_errors: list[float] = [] - for context, target_model in zip(robot_contexts, target_models, strict=True): - current_pose = self._current_frame_matrix(context, configuration.q) + current_poses = self._current_frame_matrices(robot_contexts, configuration.q) + for current_pose, target_model in zip(current_poses, target_models, strict=True): position_error, orientation_error = compute_pose_error(current_pose, target_model) position_errors.append(position_error) orientation_errors.append(orientation_error) @@ -469,26 +452,43 @@ def _solve_multi_frame( ) def _get_robot_context( - self, world: WorldSpec, robot_id: WorldRobotID, frame_name: str | None = None + self, + world: WorldSpec, + robot_id: WorldRobotID, + frame_name: str | None = None, + config: RobotModelConfig | None = None, ) -> _PinkRobotContext: - config = world.get_robot_config(robot_id) + config = config or world.get_robot_config(robot_id) target_frame = frame_name or config.end_effector_link if target_frame is None: raise ValueError(f"Robot '{robot_id}' has no end-effector frame configured") - cache_key = f"{robot_id}:{target_frame}" - if ( - cache_key not in self._robot_contexts - and frame_name is None - and str(robot_id) in self._robot_contexts - ): - return self._robot_contexts[str(robot_id)] - if cache_key not in self._robot_contexts: - self._robot_contexts[cache_key] = self._build_robot_context(config, target_frame) - return self._robot_contexts[cache_key] + model_context = self._get_robot_model_context(robot_id, config) + frame_id = self._frame_id_for_model_context(model_context, target_frame) + return _PinkRobotContext( + model=model_context.model, + data=model_context.model.createData(), + frame_id=frame_id, + frame_name=target_frame, + mapping=model_context.mapping, + neutral_q=model_context.neutral_q, + ) - def _build_robot_context( - self, config: RobotModelConfig, frame_name: str | None = None - ) -> _PinkRobotContext: + def _get_robot_model_context( + self, robot_id: WorldRobotID, config: RobotModelConfig + ) -> _PinkRobotModelContext: + cache_key = _robot_model_cache_key(robot_id, config) + if cache_key not in self._robot_model_contexts: + self._robot_model_contexts[cache_key] = self._build_robot_model_context(config) + return self._robot_model_contexts[cache_key] + + def _frame_id_for_model_context( + self, model_context: _PinkRobotModelContext, frame_name: str + ) -> int: + if frame_name not in model_context.frame_ids: + model_context.frame_ids[frame_name] = _get_frame_id(model_context.model, frame_name) + return model_context.frame_ids[frame_name] + + def _build_robot_model_context(self, config: RobotModelConfig) -> _PinkRobotModelContext: assert pinocchio is not None model_path = Path(config.model_path).resolve() if not model_path.exists(): @@ -508,19 +508,13 @@ def _build_robot_context( ) model = pinocchio.buildModelFromUrdf(str(prepared_path)) model = _lock_uncontrolled_model_joints(pinocchio, model, config) - - data = model.createData() - target_frame = frame_name or config.end_effector_link - if target_frame is None: - raise ValueError("Robot model has no end-effector frame configured") - frame_id = _get_frame_id(model, target_frame) mapping = _build_joint_mapping(model, config) - return _PinkRobotContext( + neutral_q = np.asarray(pinocchio.neutral(model), dtype=np.float64) + return _PinkRobotModelContext( model=model, - data=data, - frame_id=frame_id, - frame_name=target_frame, mapping=mapping, + neutral_q=neutral_q, + frame_ids={}, ) def _initial_q( @@ -532,42 +526,58 @@ def _initial_q( attempt: int, ) -> NDArray[np.float64]: assert pinocchio is not None - neutral = pinocchio.neutral(context.model) - q = np.array(neutral, dtype=np.float64) + neutral = context.neutral_q + if neutral is None: + neutral = np.asarray(pinocchio.neutral(context.model), dtype=np.float64) + q = np.array(neutral, dtype=np.float64, copy=True) if attempt == 0: positions = _seed_positions_for_mapping(seed, context.mapping) else: positions = np.random.uniform(lower_limits, upper_limits) - for value, idx_q in zip(positions, context.mapping.idx_q, strict=True): - q[idx_q] = value + q[context.mapping.idx_q_array] = positions return q def _q_to_dimos_positions( self, context: _PinkRobotContext, q: NDArray[np.float64] ) -> NDArray[np.float64]: - return np.array([q[idx_q] for idx_q in context.mapping.idx_q], dtype=np.float64) + return np.asarray(q[context.mapping.idx_q_array], dtype=np.float64) - def _current_frame_matrix( - self, context: _PinkRobotContext, q: NDArray[np.float64] - ) -> NDArray[np.float64]: + def _current_frame_matrices( + self, contexts: Sequence[_PinkRobotContext], q: NDArray[np.float64] + ) -> list[NDArray[np.float64]]: assert pinocchio is not None - pinocchio.forwardKinematics(context.model, context.data, q) - pinocchio.updateFramePlacements(context.model, context.data) - placement = context.data.oMf[context.frame_id] - matrix: NDArray[np.float64] = np.eye(4) - matrix[:3, :3] = np.asarray(placement.rotation, dtype=np.float64) - matrix[:3, 3] = np.asarray(placement.translation, dtype=np.float64) - return matrix + primary_context = contexts[0] + pinocchio.forwardKinematics(primary_context.model, primary_context.data, q) + pinocchio.updateFramePlacements(primary_context.model, primary_context.data) + return [ + _placement_to_matrix(primary_context.data.oMf[context.frame_id]) for context in contexts + ] def _target_in_model_frame( self, config: RobotModelConfig, target_pose: PoseStamped + ) -> NDArray[np.float64]: + base_world_inverse = np.linalg.inv(pose_to_matrix(config.base_pose)) + return self._target_in_model_frame_with_base_inverse(target_pose, base_world_inverse) + + def _targets_in_model_frame( + self, + config: RobotModelConfig, + pose_targets: Mapping[PlanningGroup, PoseStamped], + ) -> dict[PlanningGroup, NDArray[np.float64]]: + base_world_inverse = np.linalg.inv(pose_to_matrix(config.base_pose)) + return { + group: self._target_in_model_frame_with_base_inverse(pose, base_world_inverse) + for group, pose in pose_targets.items() + } + + def _target_in_model_frame_with_base_inverse( + self, target_pose: PoseStamped, base_world_inverse: NDArray[np.float64] ) -> NDArray[np.float64]: target_world = pose_to_matrix(target_pose) - base_world = pose_to_matrix(config.base_pose) target_model: NDArray[np.float64] = np.asarray( - np.linalg.inv(base_world) @ target_world, dtype=np.float64 + base_world_inverse @ target_world, dtype=np.float64 ) return target_model @@ -610,9 +620,30 @@ def _build_joint_mapping(model: Any, config: RobotModelConfig) -> _JointMapping: dimos_joint_names=list(config.joint_names), model_joint_names=model_joint_names, idx_q=idx_q, + idx_q_array=np.asarray(idx_q, dtype=np.int64), + ) + + +def _robot_model_cache_key(robot_id: WorldRobotID, config: RobotModelConfig) -> tuple[object, ...]: + return ( + str(robot_id), + str(Path(config.model_path).resolve()), + tuple(config.joint_names), + config.base_link, + tuple(sorted((name, str(path.resolve())) for name, path in config.package_paths.items())), + tuple(sorted(config.xacro_args.items())), + config.auto_convert_meshes, + config.strip_model_world_joint, ) +def _placement_to_matrix(placement: Any) -> NDArray[np.float64]: + matrix: NDArray[np.float64] = np.eye(4) + matrix[:3, :3] = np.asarray(placement.rotation, dtype=np.float64) + matrix[:3, 3] = np.asarray(placement.translation, dtype=np.float64) + return matrix + + def _lock_uncontrolled_model_joints(pinocchio: Any, model: Any, config: RobotModelConfig) -> Any: """Return a Pinocchio model reduced to the joints controlled by config.""" controlled_joint_names = set(config.joint_names) diff --git a/dimos/manipulation/planning/kinematics/test_pink_ik.py b/dimos/manipulation/planning/kinematics/test_pink_ik.py index caa7a10000..df1f19738f 100644 --- a/dimos/manipulation/planning/kinematics/test_pink_ik.py +++ b/dimos/manipulation/planning/kinematics/test_pink_ik.py @@ -35,6 +35,7 @@ _build_joint_mapping, _lock_uncontrolled_model_joints, _PinkRobotContext, + _PinkRobotModelContext, _seed_for_robot_config, _seed_positions_for_mapping, ) @@ -67,7 +68,7 @@ def __init__(self, translation: np.ndarray) -> None: class _FakeData: def __init__(self) -> None: self.q = np.zeros(3) - self.oMf = [_FakePlacement(np.zeros(3))] + self.oMf = [_FakePlacement(np.zeros(3)), _FakePlacement(np.zeros(3))] class _FakeModel: @@ -139,6 +140,7 @@ def forward_kinematics(model: _FakeModel, data: _FakeData, q: np.ndarray) -> Non def update_frame_placements(model: _FakeModel, data: _FakeData) -> None: data.oMf[0] = _FakePlacement(data.q.copy()) + data.oMf[1] = _FakePlacement(data.q.copy()) pinocchio.forwardKinematics = forward_kinematics # type: ignore[attr-defined] pinocchio.updateFramePlacements = update_frame_placements # type: ignore[attr-defined] @@ -196,7 +198,7 @@ class _TestPinkIK(PinkIK): def __init__(self, converge: bool = True) -> None: self.config = PinkIKConfig(max_iterations=3) _install_fake_modules(converge=converge) - self._robot_contexts = {} + self._robot_model_contexts = {} def _pink_ik(converge: bool = True) -> PinkIK: @@ -215,6 +217,24 @@ def _context() -> _PinkRobotContext: ) +def _patch_robot_contexts( + monkeypatch: pytest.MonkeyPatch, + ik: PinkIK, + contexts_by_frame: dict[str, _PinkRobotContext], +) -> None: + def fake_get_robot_context( + world: object, + robot_id: str, + frame_name: str | None = None, + config: RobotModelConfig | None = None, + ) -> _PinkRobotContext: + del world, robot_id, config + target_frame = frame_name or "tool" + return contexts_by_frame[target_frame] + + monkeypatch.setattr(ik, "_get_robot_context", fake_get_robot_context) + + class _FakeWorld: is_finalized = True @@ -264,6 +284,26 @@ def check_config_collision_free(self, robot_id: str, joint_state: JointState) -> return self.collision_free +class _CountingWorld(_FakeWorld): + def __init__(self, collision_free: bool = True) -> None: + super().__init__(collision_free=collision_free) + self.scratch_calls = 0 + self.current_state_calls = 0 + self.joint_limit_calls = 0 + + def scratch_context(self) -> nullcontext[None]: + self.scratch_calls += 1 + return nullcontext(None) + + def get_joint_state(self, ctx: object, robot_id: str) -> JointState: + self.current_state_calls += 1 + return super().get_joint_state(ctx, robot_id) + + def get_joint_limits(self, robot_id: str) -> tuple[np.ndarray, np.ndarray]: + self.joint_limit_calls += 1 + return super().get_joint_limits(robot_id) + + class _FakeMultiRobotWorld: is_finalized = True @@ -304,6 +344,21 @@ def get_joint_state(self, ctx: object, robot_id: str) -> JointState: config = self.get_robot_config(robot_id) return JointState(name=list(config.joint_names), position=[0.0] * len(config.joint_names)) + def get_joint_limits(self, robot_id: str) -> tuple[np.ndarray, np.ndarray]: + config = self.get_robot_config(robot_id) + count = len(config.joint_names) + return np.full(count, -1.0), np.full(count, 1.0) + + +class _CountingMultiRobotWorld(_FakeMultiRobotWorld): + def __init__(self) -> None: + super().__init__() + self.joint_limit_calls: list[str] = [] + + def get_joint_limits(self, robot_id: str) -> tuple[np.ndarray, np.ndarray]: + self.joint_limit_calls.append(robot_id) + return super().get_joint_limits(robot_id) + def test_create_kinematics_pink_missing_dependency_is_actionable( monkeypatch: pytest.MonkeyPatch, @@ -386,6 +441,72 @@ def test_seed_for_robot_config_uses_complete_global_seed_without_world() -> None assert result.position == [1.0, 2.0, 3.0] +def test_solve_pose_targets_complete_seed_does_not_read_world_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ik = _pink_ik(converge=True) + context = _context() + context.frame_name = "wrist_tool" + context.frame_id = 1 + _patch_robot_contexts(monkeypatch, ik, {"wrist_tool": context}) + world = _CountingWorld(collision_free=True) + + def fake_solve_single(**_: object) -> IKResult: + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState( + name=["joint_a", "joint_b", "joint_c"], position=[0.1, 0.2, 0.3] + ), + ) + + monkeypatch.setattr(ik, "_solve_single", fake_solve_single) + + result = ik.solve_pose_targets( + world=cast("Any", world), + pose_targets={world.groups["arm/wrist"]: _pose_stamped(0.1, 0.0, 0.0)}, + seed=JointState( + name=["arm/joint_a", "arm/joint_b", "arm/joint_c"], position=[1.0, 2.0, 3.0] + ), + max_attempts=1, + ) + + assert result.status == IKStatus.SUCCESS + assert world.scratch_calls == 0 + assert world.current_state_calls == 0 + + +def test_solve_pose_targets_incomplete_seed_reads_world_state_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ik = _pink_ik(converge=True) + context = _context() + context.frame_name = "wrist_tool" + context.frame_id = 1 + _patch_robot_contexts(monkeypatch, ik, {"wrist_tool": context}) + world = _CountingWorld(collision_free=True) + + def fake_solve_single(**_: object) -> IKResult: + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState( + name=["joint_a", "joint_b", "joint_c"], position=[0.1, 0.2, 0.3] + ), + ) + + monkeypatch.setattr(ik, "_solve_single", fake_solve_single) + + result = ik.solve_pose_targets( + world=cast("Any", world), + pose_targets={world.groups["arm/wrist"]: _pose_stamped(0.1, 0.0, 0.0)}, + seed=JointState(name=["arm/joint_a"], position=[1.0]), + max_attempts=1, + ) + + assert result.status == IKStatus.SUCCESS + assert world.scratch_calls == 1 + assert world.current_state_calls == 1 + + def test_mapping_failure_for_missing_joint() -> None: config = _robot_config() config.joint_names = ["joint_a", "missing", "joint_c"] @@ -459,10 +580,10 @@ def test_solve_single_reports_non_convergence() -> None: assert "did not converge" in result.message -def test_solve_does_not_filter_collision_candidates() -> None: +def test_solve_does_not_filter_collision_candidates(monkeypatch: pytest.MonkeyPatch) -> None: ik = _pink_ik(converge=True) context = _context() - ik._robot_contexts = {"robot:tool": context} + _patch_robot_contexts(monkeypatch, ik, {"tool": context}) result = ik.solve( world=cast("Any", _FakeWorld(collision_free=False)), @@ -485,7 +606,7 @@ def test_solve_pose_targets_returns_selected_resolved_joints_and_group_tip( context = _context() context.frame_name = "wrist_tool" context.frame_id = 1 - ik._robot_contexts = {"robot:wrist_tool": context} + _patch_robot_contexts(monkeypatch, ik, {"wrist_tool": context}) seen_frame_names: list[str] = [] def fake_solve_single(**kwargs: object) -> IKResult: @@ -534,7 +655,7 @@ def test_solve_pose_targets_same_robot_uses_one_multi_frame_solve( wrist_context.frame_name = "wrist_tool" wrist_context.frame_id = 1 tool_context = _context() - ik._robot_contexts = {"robot:wrist_tool": wrist_context, "robot:tool": tool_context} + _patch_robot_contexts(monkeypatch, ik, {"wrist_tool": wrist_context, "tool": tool_context}) world = _FakeWorld(collision_free=True) tool_group = PlanningGroup( id="arm/tool", @@ -587,6 +708,97 @@ def fake_solve_multi_frame(**kwargs: object) -> IKResult: assert result.joint_state.position == [0.1, 0.2, 0.3] +def test_solve_pose_targets_same_robot_builds_one_model_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ik = _pink_ik(converge=True) + world = _FakeWorld(collision_free=True) + tool_group = PlanningGroup( + id="arm/tool", + robot_name="arm", + group_name="tool", + joint_names=("arm/joint_c",), + local_joint_names=("joint_c",), + base_link="base", + tip_link="tool", + ) + model = _FakeModel() + build_calls = 0 + + def fake_build_model_context(config: RobotModelConfig) -> _PinkRobotModelContext: + nonlocal build_calls + build_calls += 1 + mapping = _build_joint_mapping(model, config) + return _PinkRobotModelContext( + model=model, + mapping=mapping, + neutral_q=np.zeros(model.nq), + frame_ids={}, + ) + + def fake_solve_multi_frame(**_: object) -> IKResult: + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState( + name=["joint_a", "joint_b", "joint_c"], position=[0.1, 0.2, 0.3] + ), + ) + + monkeypatch.setattr(ik, "_build_robot_model_context", fake_build_model_context) + monkeypatch.setattr(ik, "_solve_multi_frame", fake_solve_multi_frame) + + result = ik.solve_pose_targets( + world=cast("Any", world), + pose_targets={ + world.groups["arm/wrist"]: _pose_stamped(0.1, 0.0, 0.0), + tool_group: _pose_stamped(0.2, 0.0, 0.0), + }, + seed=JointState( + name=["arm/joint_a", "arm/joint_b", "arm/joint_c"], position=[0.0, 0.0, 0.0] + ), + max_attempts=1, + ) + + assert result.status == IKStatus.SUCCESS + assert build_calls == 1 + + +def test_solve_multi_frame_updates_fk_once_per_iteration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ik = _pink_ik(converge=True) + ik.config = PinkIKConfig(max_iterations=1) + pinocchio = cast("Any", pink_ik_module.pinocchio) + original_forward_kinematics = pinocchio.forwardKinematics + forward_calls = 0 + + def counting_forward_kinematics(model: _FakeModel, data: _FakeData, q: np.ndarray) -> None: + nonlocal forward_calls + forward_calls += 1 + original_forward_kinematics(model, data, q) + + monkeypatch.setattr(pinocchio, "forwardKinematics", counting_forward_kinematics) + wrist_context = _context() + wrist_context.frame_name = "wrist_tool" + wrist_context.frame_id = 1 + tool_context = _context() + target = np.eye(4) + target[:3, 3] = [0.1, 0.0, 0.0] + + result = ik._solve_multi_frame( + robot_contexts=[wrist_context, tool_context], + target_models=[target, target], + seed_q=np.zeros(3), + lower_limits=np.array([-1.0, -1.0, -1.0]), + upper_limits=np.array([1.0, 1.0, 1.0]), + position_tolerance=0.001, + orientation_tolerance=0.01, + ) + + assert result.status == IKStatus.NO_SOLUTION + assert forward_calls == 1 + + def test_target_in_model_frame_converts_world_pose_through_robot_base() -> None: ik = _pink_ik(converge=True) config = _robot_config() @@ -606,7 +818,7 @@ def test_solve_pose_targets_passes_world_target_to_solver_in_model_frame( context = _context() context.frame_name = "wrist_tool" context.frame_id = 1 - ik._robot_contexts = {"robot:wrist_tool": context} + _patch_robot_contexts(monkeypatch, ik, {"wrist_tool": context}) world = _FakeWorld(collision_free=True) world.config.base_pose = _pose_stamped(1.0, 2.0, 0.0, yaw=np.pi / 2.0) seen_target_models: list[np.ndarray] = [] @@ -707,10 +919,107 @@ def fake_solve_pose_targets_for_robot(**kwargs: object) -> IKResult: assert result.joint_state.position == [1.0, 2.0] +def test_solve_pose_targets_returns_first_robot_failure_before_touching_later_limits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ik = _pink_ik(converge=True) + world = _CountingMultiRobotWorld() + left_group = PlanningGroup( + id="left/arm", + robot_name="left", + group_name="arm", + joint_names=("left/joint_a",), + local_joint_names=("joint_a",), + base_link="base", + tip_link="tool", + ) + right_group = PlanningGroup( + id="right/arm", + robot_name="right", + group_name="arm", + joint_names=("right/joint_c",), + local_joint_names=("joint_c",), + base_link="base", + tip_link="tool", + ) + + def fail_first_robot(**_: object) -> IKResult: + return IKResult(status=IKStatus.NO_SOLUTION, joint_state=None, message="left failed") + + monkeypatch.setattr(ik, "_solve_pose_targets_for_robot", fail_first_robot) + + result = ik.solve_pose_targets( + world=cast("Any", world), + pose_targets={ + left_group: _pose_stamped(0.1, 0.0, 0.0), + right_group: _pose_stamped(0.2, 0.0, 0.0), + }, + seed=JointState( + name=["left/joint_a", "left/joint_b", "right/joint_c"], + position=[0.0, 0.0, 0.0], + ), + max_attempts=1, + ) + + assert result.status == IKStatus.NO_SOLUTION + assert result.message == "left failed" + assert world.joint_limit_calls == ["left_robot"] + + +def test_solve_pose_targets_auxiliary_robot_does_not_read_joint_limits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ik = _pink_ik(converge=True) + world = _CountingMultiRobotWorld() + left_group = PlanningGroup( + id="left/arm", + robot_name="left", + group_name="arm", + joint_names=("left/joint_a",), + local_joint_names=("joint_a",), + base_link="base", + tip_link="tool", + ) + right_auxiliary = PlanningGroup( + id="right/gripper", + robot_name="right", + group_name="gripper", + joint_names=("right/joint_c",), + local_joint_names=("joint_c",), + base_link="base", + tip_link=None, + ) + + def solve_left_robot(**_: object) -> IKResult: + return IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState(name=["joint_a", "joint_b"], position=[1.0, 9.0]), + ) + + monkeypatch.setattr(ik, "_solve_pose_targets_for_robot", solve_left_robot) + + result = ik.solve_pose_targets( + world=cast("Any", world), + pose_targets={left_group: _pose_stamped(0.1, 0.0, 0.0)}, + auxiliary_groups=[right_auxiliary], + seed=JointState( + name=["left/joint_a", "left/joint_b", "right/joint_c"], + position=[0.0, 0.0, 2.0], + ), + max_attempts=1, + ) + + assert result.status == IKStatus.SUCCESS + assert result.joint_state is not None + assert result.joint_state.name == ["left/joint_a", "right/joint_c"] + assert result.joint_state.position == [1.0, 2.0] + assert world.joint_limit_calls == ["left_robot"] + + def test_solve_retries_after_joint_limit_failure(monkeypatch: pytest.MonkeyPatch) -> None: ik = _pink_ik(converge=True) context = _context() - ik._robot_contexts = {"robot:tool": context} + _patch_robot_contexts(monkeypatch, ik, {"tool": context}) calls = 0 def fake_solve_single(**_: object) -> IKResult: From 768e931ef38d182fd86378708f6a64c266401029 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 24 Jun 2026 13:43:28 -0700 Subject: [PATCH 074/110] fix: address manipulation review feedback --- dimos/control/blueprints/test_dual.py | 8 +- dimos/manipulation/manipulation_module.py | 11 +- .../planning/planning_identifiers.py | 31 ---- dimos/manipulation/visualization/types.py | 19 +-- dimos/manipulation/visualization/viser/gui.py | 6 - .../manipulation/visualization/viser/state.py | 3 +- .../viser/test_operation_worker.py | 4 - .../viser/test_viser_visualization.py | 41 +----- dimos/robot/test_all_blueprints.py | 1 - .../manipulation/planning_groups.md | 134 +++++++++++------- 10 files changed, 102 insertions(+), 156 deletions(-) delete mode 100644 dimos/manipulation/planning/planning_identifiers.py diff --git a/dimos/control/blueprints/test_dual.py b/dimos/control/blueprints/test_dual.py index 082b6ec8a0..fc82c1930a 100644 --- a/dimos/control/blueprints/test_dual.py +++ b/dimos/control/blueprints/test_dual.py @@ -16,11 +16,11 @@ import pytest -from dimos.control.blueprints.dual import coordinator_dual_xarm from dimos.control.coordinator import ControlCoordinator from dimos.core.coordination.blueprints import Blueprint from dimos.manipulation.manipulation_module import ManipulationModule from dimos.manipulation.planning.groups.identifiers import make_global_joint_names +from dimos.robot.manipulators.xarm.blueprints.basic import coordinator_dual_xarm def _dual_xarm6_planner_coordinator() -> Blueprint: @@ -77,8 +77,8 @@ def test_dual_xarm6_integrated_tasks_match_planner_robots() -> None: assert task.joint_names == make_global_joint_names(robot.name, robot.joint_names) -def test_dual_coordinator_xarm_task_names_match_manipulation_robot_defaults() -> None: +def test_dual_coordinator_xarm_task_names_match_split_blueprint() -> None: assert _coordinator_task_names(coordinator_dual_xarm) == [ - "traj_left_arm", - "traj_right_arm", + "traj_left", + "traj_right", ] diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 6e67bd95e4..f5dac19163 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -79,7 +79,6 @@ NoManipulationVisualizationConfig, ) from dimos.manipulation.visualization.factory import create_manipulation_visualization -from dimos.manipulation.visualization.types import RobotInfo from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -101,6 +100,12 @@ RobotRegistry: TypeAlias = dict[RobotName, RobotEntry] """Maps robot_name -> RobotEntry""" +RobotInfoValue: TypeAlias = ( + RobotName | WorldRobotID | list[str] | list[float] | float | None | list[PlanningGroup] +) +RobotInfoPayload: TypeAlias = dict[str, RobotInfoValue] +"""Legacy RPC payload derived from RobotModelConfig and planning-group registry.""" + class ManipulationState(Enum): """State machine for manipulation module.""" @@ -997,7 +1002,7 @@ def list_robots(self) -> list[str]: return list(self._robots.keys()) @rpc - def get_robot_info(self, robot_name: RobotName | None = None) -> RobotInfo | None: + def get_robot_info(self, robot_name: RobotName | None = None) -> RobotInfoPayload | None: """Get information about a robot. Args: @@ -1017,7 +1022,7 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> RobotInfo | Non else [] ) - info: RobotInfo = { + info: RobotInfoPayload = { "name": config.name, "world_robot_id": robot_id, "joint_names": config.joint_names, diff --git a/dimos/manipulation/planning/planning_identifiers.py b/dimos/manipulation/planning/planning_identifiers.py deleted file mode 100644 index 1acd15e824..0000000000 --- a/dimos/manipulation/planning/planning_identifiers.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Compatibility shim for planning-group identifier helpers. - -New code should import from ``dimos.manipulation.planning.groups.identifiers``. -""" - -from dimos.manipulation.planning.groups.identifiers import ( # noqa: F401 - assert_global_joint_names, - assert_local_joint_names, - assert_valid_local_joint_name, - assert_valid_robot_name, - is_global_joint_name, - local_joint_name_from_global, - make_global_joint_name, - make_global_joint_names, - make_planning_group_id, - parse_planning_group_id, -) diff --git a/dimos/manipulation/visualization/types.py b/dimos/manipulation/visualization/types.py index 8122643ecd..54fceb1e95 100644 --- a/dimos/manipulation/visualization/types.py +++ b/dimos/manipulation/visualization/types.py @@ -16,8 +16,7 @@ from typing import TypedDict -from dimos.manipulation.planning.groups.models import PlanningGroup -from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName, WorldRobotID +from dimos.manipulation.planning.spec.models import PlanningGroupID from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState @@ -45,19 +44,3 @@ class TargetSetEvaluation(TypedDict, total=False): group_poses: dict[PlanningGroupID, PoseStamped | Pose | None] position_error: float orientation_error: float - - -class RobotInfo(TypedDict, total=False): - name: RobotName - world_robot_id: WorldRobotID - joint_names: list[str] - end_effector_link: str | None - base_link: str - max_velocity: float - max_acceleration: float - has_joint_name_mapping: bool - coordinator_task_name: str | None - home_joints: list[float] | None - pre_grasp_offset: float - init_joints: list[float] | None - planning_groups: list[PlanningGroup] diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index 175d900238..bd6376c61c 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -20,7 +20,6 @@ from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName from dimos.manipulation.visualization.types import ( - RobotInfo, TargetEvaluation, TargetSetEvaluation, ) @@ -197,9 +196,6 @@ def _list_robots(self) -> list[RobotName]: def _list_planning_groups(self) -> list[PlanningGroup]: return self.manipulation_module.list_planning_groups() - def _get_robot_info(self, robot_name: RobotName) -> RobotInfo | None: - return self.manipulation_module.get_robot_info(robot_name) - def _get_init_joints(self, robot_name: RobotName) -> JointState | None: return copy_joint_state(self.manipulation_module.get_init_joints(robot_name)) @@ -321,12 +317,10 @@ def _set_scene_grid_visible(self, visible: bool) -> None: def _refresh_selected_robot_state(self) -> None: robot_name = self.state.selected_robot if robot_name is None: - self.state.robot_info = None self.state.current_joints = None self.state.current_ee_pose = None self.state.manipulation_state = self._get_module_state() return - self.state.robot_info = self._get_robot_info(robot_name) current = self._get_current_joint_state(robot_name) self.state.current_joints = list(current.position) if current is not None else None self.state.current_ee_pose = self._get_ee_pose(robot_name) diff --git a/dimos/manipulation/visualization/viser/state.py b/dimos/manipulation/visualization/viser/state.py index f16e490ffb..e793c9bf9f 100644 --- a/dimos/manipulation/visualization/viser/state.py +++ b/dimos/manipulation/visualization/viser/state.py @@ -22,7 +22,7 @@ from typing import Literal from dimos.manipulation.planning.spec.models import PlanningGroupID -from dimos.manipulation.visualization.types import RobotInfo, TargetEvaluation, TargetSetEvaluation +from dimos.manipulation.visualization.types import TargetEvaluation, TargetSetEvaluation from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.sensor_msgs.JointState import JointState from dimos.utils.logging_config import setup_logger @@ -117,7 +117,6 @@ class PanelState: target_status: TargetStatus = TargetStatus.EMPTY action_status: ActionStatus = ActionStatus.IDLE manipulation_state: str = "DISCONNECTED" - robot_info: RobotInfo | None = None current_joints: list[float] | None = None current_ee_pose: Pose | None = None cartesian_target: Pose | None = None diff --git a/dimos/manipulation/visualization/viser/test_operation_worker.py b/dimos/manipulation/visualization/viser/test_operation_worker.py index 8367d05a19..53184c91eb 100644 --- a/dimos/manipulation/visualization/viser/test_operation_worker.py +++ b/dimos/manipulation/visualization/viser/test_operation_worker.py @@ -23,7 +23,6 @@ pytest.importorskip("viser", reason="Viser optional dependency is not installed") from dimos.manipulation.planning.groups.models import PlanningGroup -from dimos.manipulation.visualization.types import RobotInfo from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig from dimos.manipulation.visualization.viser.gui import ViserPanelGui from dimos.manipulation.visualization.viser.state import ( @@ -127,9 +126,6 @@ def robot_id_for_name(self, robot_name: str) -> str | None: def get_state(self) -> str: return "IDLE" - def get_robot_info(self, robot_name: str) -> RobotInfo | None: - return None - def list_planning_groups(self) -> list[PlanningGroup]: return [] diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index 42d78c068f..ce963ce1dc 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -32,7 +32,6 @@ ForwardKinematicsResult, IKResult, ) -from dimos.manipulation.visualization.types import RobotInfo from dimos.manipulation.visualization.viser import scene as scene_module from dimos.manipulation.visualization.viser.animation import ( GroupPreviewAnimation, @@ -429,40 +428,14 @@ def get_robot_config(self, robot_name: str) -> RobotConfigStub | SimpleNamespace entry = getattr(self, "_robots", {}).get(robot_name) return entry[1] if entry is not None else None - def get_robot_info(self, robot_name: str) -> RobotInfo | None: - config = self.get_robot_config(robot_name) - if config is None: - return None - init = self.get_init_joints(robot_name) - home_joints = config.home_joints if hasattr(config, "home_joints") else None - planning_groups = getattr(self, "_planning_groups", None) - if planning_groups is None: - planning_groups = [make_planning_group_info(robot_name, config)] - else: - planning_groups = [group for group in planning_groups if group.robot_name == robot_name] - return { - "name": config.name, - "world_robot_id": self.robot_id_for_name(robot_name) or robot_name, - "joint_names": list(config.joint_names), - "planning_groups": planning_groups, - "end_effector_link": config.end_effector_link, - "base_link": config.base_link, - "max_velocity": 1.0, - "max_acceleration": 1.0, - "has_joint_name_mapping": False, - "coordinator_task_name": None, - "home_joints": list(home_joints) if home_joints is not None else None, - "pre_grasp_offset": 0.0, - "init_joints": list(init.position) if init is not None else None, - } - def list_planning_groups(self) -> list[PlanningGroup]: - groups: list[PlanningGroup] = [] - for robot_name in self.list_robots(): - info = self.get_robot_info(robot_name) - if info is not None: - groups.extend(info.get("planning_groups", [])) - return groups + planning_groups = getattr(self, "_planning_groups", None) + if planning_groups is not None: + return list(planning_groups) + return [ + make_planning_group_info(robot_name, config) + for robot_name, (_, config, _) in getattr(self, "_robots", {}).items() + ] def get_init_joints(self, robot_name: str) -> JointState | None: return getattr(self, "_init_joints", {}).get(robot_name) diff --git a/dimos/robot/test_all_blueprints.py b/dimos/robot/test_all_blueprints.py index b0929ff1b6..0655687579 100644 --- a/dimos/robot/test_all_blueprints.py +++ b/dimos/robot/test_all_blueprints.py @@ -50,7 +50,6 @@ "coordinator-xarm6", "coordinator-xarm7", "dual-xarm6-planner-coordinator", - "dual-xarm7-planner-coordinator", "teleop-hosted-go2", "teleop-hosted-xarm7", "teleop-quest-dual", diff --git a/docs/capabilities/manipulation/planning_groups.md b/docs/capabilities/manipulation/planning_groups.md index 24343b5bc1..5fbf952da0 100644 --- a/docs/capabilities/manipulation/planning_groups.md +++ b/docs/capabilities/manipulation/planning_groups.md @@ -1,32 +1,36 @@ # Manipulation Planning Groups Planning groups are named, selectable kinematic chains used by manipulation -planning. They separate the hardware robot identity from the part of the robot -being planned. +planning. They let APIs target a specific part of a robot, such as an arm or +torso, without confusing that group with the robot's hardware identity. ## Concepts | Concept | Meaning | |---------|---------| -| Planning group | A named serial chain of controllable robot joints. | +| Robot name | The configured robot ID in `RobotModelConfig.name`. | +| Planning group | A named serial chain of controllable joints on one robot. | | Planning group ID | Stable API ID in the form `{robot_name}/{group_name}`. | +| Local joint name | Joint name inside a robot model, such as `joint1`. | | Global joint name | Boundary-level joint name in the form `{robot_name}/{local_joint_name}`. | -| Generated plan | Minimal planning artifact containing selected group IDs and one synchronized global-joint path. | -| Auxiliary group | A group selected for a pose request without receiving its own pose target. | +| Generated plan | Planning artifact containing selected group IDs and one synchronized global-joint path. | +| Auxiliary group | A selected group that contributes free DOFs to a pose plan without receiving its own pose target. | -Local URDF/SRDF joint names stay inside robot-scoped APIs, model parsing, and -backend internals. Flat planning states and generated plan paths require global -joint names so two robots can safely have the same local joint names. +Local URDF/SRDF joint names stay inside robot-scoped configuration, model +parsing, and backend internals. Flat planning states and generated plan paths +use global joint names so multiple robots can safely share local names such as +`joint1`. -## Planning group sources +## Discovering planning groups -DimOS discovers planning groups in this order: +DimOS discovers planning groups for each `RobotModelConfig` in this order: -1. Explicit `srdf_path` on `RobotConfig` / `RobotModelConfig`. -2. Conservative SRDF auto-discovery near the model path, with a visible warning. -3. Fallback generation of one `{robot_name}/manipulator` group if the configured - controllable joints form exactly one unambiguous serial chain. -4. Error if no SRDF exists and fallback cannot infer a single chain. +1. Explicit `planning_groups` on the robot model config. +2. Explicit `srdf_path` on the robot model config. +3. Conservative SRDF auto-discovery near the model path, with a warning. +4. Fallback generation of one `{robot_name}/manipulator` group when the + configured controllable joints form exactly one unambiguous serial chain. +5. Error if no SRDF or fallback chain can provide a single valid group. Supported SRDF group forms: @@ -45,51 +49,76 @@ Supported SRDF group forms: ``` Unsupported SRDF forms are skipped with warnings: link groups, nested group -references, mixed group declarations, branching/non-serial groups, and SRDF -`` metadata. A chain group's `tip_link` is the pose target frame. -An ordered joint-list group may be pose-targeted only when DimOS can validate a +references, mixed group declarations, branching or non-serial groups, and SRDF +`` metadata. A chain group's `tip_link` is its pose target frame. +An ordered joint-list group can be pose-targeted only when DimOS can validate a unique serial target frame. ## Fallback behavior -When no SRDF is available, fallback uses `RobotModelConfig.joint_names` as the -candidate controllable set. This field is the robot's ordered local model joint -set, not an implicit planning group. +When no SRDF or explicit group config is available, fallback uses +`RobotModelConfig.joint_names` as the candidate controllable set. This field is +the robot's ordered local model joint set, not an implicit planning group. Fallback succeeds only when those joints form one unambiguous serial chain. It -allows prismatic joints in the middle of the chain and strips only terminal/tip +allows prismatic joints in the middle of the chain and strips only terminal tip prismatic joints, which usually represent gripper fingers. The generated group name is always `manipulator`. -## Planning APIs +## Current APIs -Planning APIs select groups explicitly. Descriptors returned by -`WorldSpec.list_planning_groups()` can be passed where a group ID is accepted; -the API normalizes them back to IDs and re-resolves current world state. +Use `list_planning_groups()` to discover group IDs and capabilities before +planning: ```python skip -# Joint-space planning for one group. -manip.plan_to_joint_targets({ - "left_arm/manipulator": JointState( - name=["joint1", "joint2"], - position=[0.2, -0.1], - ) -}) - -# Pose planning for an arm while a torso/waist group participates as free DOFs. -manip.plan_to_poses( - {"robot/arm": target_pose}, - auxiliary_groups=["robot/torso"], +groups = manip.list_planning_groups() +pose_groups = [group for group in groups if group.has_pose_target] +group_id = pose_groups[0].id +``` + +Joint-space planning targets group IDs and uses local joint names inside each +target `JointState`: + +```python skip +ok = manip.plan_to_joint_targets( + { + "left_arm/manipulator": JointState( + name=["joint1", "joint2", "joint3", "joint4", "joint5", "joint6"], + position=[0.0, -0.4, 0.2, 0.0, 0.3, 0.0], + ) + } +) +``` + +Pose planning targets pose-capable group IDs. Add auxiliary groups when another +chain should participate as free DOFs but does not have its own pose target: + +```python skip +ok = manip.plan_to_pose_targets( + {"left_arm/manipulator": target_pose}, + auxiliary_groups=["torso/manipulator"], ) +``` + +After a successful planning call, preview and execution use the module's current +stored plan: + +```python skip +manip.preview_plan() +manip.execute_plan() +``` -plan = manip._last_plan +Callers that already hold a `GeneratedPlan` may pass it explicitly: + +```python skip manip.preview_plan(plan) manip.execute_plan(plan) ``` -For robot-scoped joint-space planning, unnamed vectors are interpreted in robot -model joint order. If names are provided, they must be local model joint names: -no global names, missing joints, extra joints, or partial joint sets. +For robot-scoped compatibility APIs, unnamed joint vectors are interpreted in +the robot model's configured joint order. If names are provided, they must be +local model joint names: no global names, missing joints, extra joints, or +partial joint sets. ## Generated plans and execution @@ -106,18 +135,17 @@ task, orders each trajectory by the robot's configured local joint order, writes global joint names at the coordinator boundary, and invokes each trajectory controller. Controllers remain planning-group agnostic. -Multi-task dispatch is not atomic in this change: if one trajectory task accepts -and a later task rejects, DimOS reports the rejection but does not roll back the -accepted task. +Multi-task dispatch is not atomic: if one trajectory task accepts and a later +task rejects, DimOS reports the rejection but does not roll back the accepted +task. -## Compatibility planning config fields +## Compatibility config fields -`RobotConfig.base_link`, `RobotConfig.base_pose`, `RobotModelConfig.base_link`, `RobotModelConfig.base_pose`, and -`RobotModelConfig.end_effector_link` remain as compatibility fields for the -current Drake weld/placement behavior and robot-scoped compatibility helpers. -New planning logic should use model/SRDF structure and planning group base/tip -links instead. +`RobotModelConfig.end_effector_link` remain compatibility fields for the current +Drake weld/placement behavior and older robot-scoped helpers. New planning logic +should prefer model/SRDF structure and planning group base/tip links. -Robot placement should be encoded in URDF/xacro/MJCF. `joint_names` remains -supported and should describe the ordered controllable local model joint set. +Robot placement can be encoded either in model assets or in `base_pose`, +depending on the blueprint. `joint_names` remains supported and should describe +the ordered controllable local model joint set. From ec936f1bc5dea2941ed28014844fb016c1f2a701 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 24 Jun 2026 18:45:50 -0700 Subject: [PATCH 075/110] feat: add composite roboplan multi-robot planning --- CONTEXT.md | 21 + dimos/manipulation/manipulation_module.py | 6 +- dimos/manipulation/planning/factory.py | 80 +- .../planning/world/roboplan_world.py | 1640 +++++++++++++++++ dimos/manipulation/test_planning_factory.py | 195 ++ dimos/manipulation/test_roboplan_world.py | 976 ++++++++++ .../manipulators/xarm/blueprints/basic.py | 11 +- dimos/robot/test_all_blueprints.py | 1 + ...001-generated-composite-roboplan-models.md | 5 + docs/capabilities/manipulation/readme.md | 15 + .../.openspec.yaml | 2 + .../design.md | 104 ++ .../proposal.md | 28 + .../spec.md | 113 ++ .../tasks.md | 71 + .../.openspec.yaml | 2 + .../design.md | 117 ++ .../proposal.md | 42 + .../spec.md | 90 + .../tasks.md | 49 + .../spec.md | 113 ++ .../spec.md | 90 + pyproject.toml | 1 + 23 files changed, 3757 insertions(+), 15 deletions(-) create mode 100644 CONTEXT.md create mode 100644 dimos/manipulation/planning/world/roboplan_world.py create mode 100644 dimos/manipulation/test_planning_factory.py create mode 100644 dimos/manipulation/test_roboplan_world.py create mode 100644 docs/adr/0001-generated-composite-roboplan-models.md create mode 100644 openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/design.md create mode 100644 openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/proposal.md create mode 100644 openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/specs/roboplan-composite-multi-robot-planning/spec.md create mode 100644 openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/tasks.md create mode 100644 openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/design.md create mode 100644 openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/proposal.md create mode 100644 openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/specs/roboplan-planning-group-native-order/spec.md create mode 100644 openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/tasks.md create mode 100644 openspec/specs/roboplan-composite-multi-robot-planning/spec.md create mode 100644 openspec/specs/roboplan-planning-group-native-order/spec.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..0d54a33bb1 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,21 @@ +# DimOS Manipulation Planning Context + +This context defines domain language for DimOS manipulation planning. It captures stable planning concepts, not implementation decisions. + +## Language + +**Planning group**: +A named subset of a robot model's joints and frames that can be selected as a planning unit. +_Avoid_: move group, joint group + +**Composite planning group**: +A planning group that represents coordinated motion across multiple selected planning groups. +_Avoid_: group combination, combined groups, multi-group plan + +**Composite RoboPlan model**: +A RoboPlan-facing robot model that represents multiple registered robot models as one planning scene. +_Avoid_: combined URDF, merged robot scene + +**Planning world**: +The authoritative belief state for manipulation planning, including robot state and scene state used by planners. +_Avoid_: planner context, backend instance diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index f5dac19163..1be640c98f 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -121,6 +121,7 @@ class ManipulationModuleConfig(ModuleConfig): """Configuration for ManipulationModule.""" robots: list[RobotModelConfig] = Field(default_factory=list) + world_backend: str = "drake" planning_timeout: float = 10.0 visualization: ManipulationVisualizationConfig = Field( default_factory=NoManipulationVisualizationConfig @@ -203,9 +204,12 @@ def _initialize_planning(self) -> None: logger.warning("No robots configured, planning disabled") return - world = create_world(visualization=self.config.visualization) + world = create_world( + backend=self.config.world_backend, visualization=self.config.visualization + ) planning_specs = create_planning_specs( world=world, + world_backend=self.config.world_backend, planner_name=self.config.planner_name, kinematics_name=self.config.kinematics_name, kinematics=self.config.kinematics, diff --git a/dimos/manipulation/planning/factory.py b/dimos/manipulation/planning/factory.py index c1eca50e90..b37dcadea5 100644 --- a/dimos/manipulation/planning/factory.py +++ b/dimos/manipulation/planning/factory.py @@ -17,7 +17,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from dimos.manipulation.planning.kinematics.config import ( DrakeOptimizationKinematicsConfig, @@ -49,19 +49,54 @@ class PlanningSpecs: planner: PlannerSpec +SUPPORTED_WORLD_BACKENDS = ("drake", "roboplan") +SUPPORTED_PLANNERS = ("rrt_connect", "roboplan") +SUPPORTED_KINEMATICS = ("jacobian", "drake_optimization", "pink") + + +def validate_backend_combination( + *, + world_backend: str = "drake", + planner_name: str = "rrt_connect", + kinematics_name: str = "jacobian", +) -> None: + """Validate manipulation backend choices before constructing the stack.""" + if world_backend not in SUPPORTED_WORLD_BACKENDS: + raise ValueError( + f"Unknown backend: {world_backend}. Available: {list(SUPPORTED_WORLD_BACKENDS)}" + ) + if planner_name not in SUPPORTED_PLANNERS: + raise ValueError(f"Unknown planner: {planner_name}. Available: {list(SUPPORTED_PLANNERS)}") + if kinematics_name not in SUPPORTED_KINEMATICS: + raise ValueError( + f"Unknown kinematics solver: {kinematics_name}. Available: {list(SUPPORTED_KINEMATICS)}" + ) + + if planner_name == "roboplan" and world_backend != "roboplan": + raise ValueError('planner_name="roboplan" requires world_backend="roboplan"') + if kinematics_name == "drake_optimization" and world_backend != "drake": + raise ValueError('kinematics_name="drake_optimization" requires world_backend="drake"') + + def create_world( backend: str = "drake", visualization: ManipulationVisualizationConfig | None = None, **kwargs: Any, ) -> WorldSpec: - """Create a world instance. backend='drake' only for now.""" + """Create a world instance for the selected planning backend.""" visualization = visualization or NoManipulationVisualizationConfig() + enable_viz = visualization.requires_world_visualization + if backend == "drake": from dimos.manipulation.planning.world.drake_world import DrakeWorld - return DrakeWorld(enable_viz=visualization.requires_world_visualization, **kwargs) - else: - raise ValueError(f"Unknown backend: {backend}. Available: ['drake']") + return DrakeWorld(enable_viz=enable_viz, **kwargs) + if backend == "roboplan": + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + return cast("WorldSpec", RoboPlanWorld(enable_viz=enable_viz, **kwargs)) + + raise ValueError(f"Unknown backend: {backend}. Available: {list(SUPPORTED_WORLD_BACKENDS)}") def create_kinematics( @@ -93,19 +128,34 @@ def create_kinematics( def create_planner( name: str = "rrt_connect", + world: WorldSpec | None = None, + world_backend: str | None = None, **kwargs: Any, ) -> PlannerSpec: - """Create motion planner. name='rrt_connect'.""" + """Create motion planner. name='rrt_connect'|'roboplan'. + + RoboPlan-native planning is scene/backend-coupled, so `name='roboplan'` + returns the RoboPlan world object itself as the planner. + """ if name == "rrt_connect": from dimos.manipulation.planning.planners.rrt_planner import RRTConnectPlanner return RRTConnectPlanner(**kwargs) - else: - raise ValueError(f"Unknown planner: {name}. Available: ['rrt_connect']") + if name == "roboplan": + if world_backend != "roboplan" or world is None: + raise ValueError('planner_name="roboplan" requires world_backend="roboplan"') + if not hasattr(world, "plan_selected_joint_path"): + raise ValueError( + "RoboPlan-native planner requires a group-native RoboPlan world planner object" + ) + return cast("PlannerSpec", world) + + raise ValueError(f"Unknown planner: {name}. Available: {list(SUPPORTED_PLANNERS)}") def create_planning_specs( world: WorldSpec, + world_backend: str = "drake", planner_name: str = "rrt_connect", kinematics_name: str | None = None, kinematics: ManipulationKinematicsConfig | None = None, @@ -115,25 +165,35 @@ def create_planning_specs( if kinematics_name is not None: kinematics = kinematics_config_from_name(kinematics_name) + if kinematics is None: + kinematics = kinematics_config_from_name("pink") + + validate_backend_combination( + world_backend=world_backend, + planner_name=planner_name, + kinematics_name=kinematics.backend, + ) return PlanningSpecs( world_monitor=WorldMonitor(world=world), kinematics=create_kinematics(config=kinematics), - planner=create_planner(name=planner_name), + planner=create_planner(name=planner_name, world=world, world_backend=world_backend), ) def create_planning_stack( robot_config: Any, + world_backend: str = "drake", visualization: ManipulationVisualizationConfig | None = None, planner_name: str = "rrt_connect", kinematics_name: str | None = None, kinematics: ManipulationKinematicsConfig | None = None, ) -> tuple[WorldSpec, KinematicsSpec, PlannerSpec, str]: """Create complete planning stack. Returns (world, kinematics, planner, robot_id).""" - world = create_world(visualization=visualization) + world = create_world(backend=world_backend, visualization=visualization) planning_specs = create_planning_specs( world=world, + world_backend=world_backend, planner_name=planner_name, kinematics_name=kinematics_name, kinematics=kinematics, diff --git a/dimos/manipulation/planning/world/roboplan_world.py b/dimos/manipulation/planning/world/roboplan_world.py new file mode 100644 index 0000000000..a2739afc99 --- /dev/null +++ b/dimos/manipulation/planning/world/roboplan_world.py @@ -0,0 +1,1640 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""RoboPlan-backed manipulation world implementation. + +This adapter imports RoboPlan at module load time. The factory imports this module +only when the RoboPlan backend is requested, so default planning paths do not need +the optional dependency installed. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from contextlib import contextmanager +from copy import deepcopy +from dataclasses import dataclass, field, replace +from itertools import combinations, pairwise +from math import atan2, sqrt +from pathlib import Path +import tempfile +import time +from typing import TYPE_CHECKING, Any +import xml.etree.ElementTree as ET +from xml.sax.saxutils import escape + +import numpy as np + +try: + import roboplan.core as roboplan_core + import roboplan.rrt as roboplan_rrt +except ImportError as exc: + raise ImportError( + "RoboPlanWorld requires the optional roboplan dependency. " + "Install the manipulation extra before selecting the roboplan backend." + ) from exc + +from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry +from dimos.manipulation.planning.groups.utils import joint_target_to_global_names +from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.manipulation.planning.spec.enums import ObstacleType, PlanningStatus +from dimos.manipulation.planning.spec.models import Obstacle, PlanningResult, WorldRobotID +from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake +from dimos.manipulation.planning.utils.path_utils import compute_path_length +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.utils.logging_config import setup_logger +from dimos.utils.transform_utils import matrix_to_pose, pose_to_matrix + +if TYPE_CHECKING: + from collections.abc import Generator + + from numpy.typing import NDArray + + from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection + from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName + +logger = setup_logger() + +_NATIVE_NAME_SEPARATOR = "__" +_COMPOSITE_GROUP_PREFIX = "_dimos_composite" + + +@dataclass +class _RoboPlanRobotData: + robot_id: WorldRobotID + config: RobotModelConfig + lower_limits: NDArray[np.float64] | None + upper_limits: NDArray[np.float64] | None + model_handle: Any = None + + +@dataclass(frozen=True) +class _NativeNameMap: + robot_name: RobotName + joint_names: dict[str, str] + link_names: dict[str, str] + + def joint(self, local_name: str) -> str: + try: + return self.joint_names[local_name] + except KeyError as exc: + raise KeyError( + f"No RoboPlan-native joint mapping for '{self.robot_name}/{local_name}'" + ) from exc + + def link(self, local_name: str) -> str: + try: + return self.link_names[local_name] + except KeyError as exc: + raise KeyError( + f"No RoboPlan-native link mapping for '{self.robot_name}/{local_name}'" + ) from exc + + +@dataclass(frozen=True) +class _RoboPlanGroupData: + group_ids: tuple[PlanningGroupID, ...] + group_name: str + native_joint_names: tuple[str, ...] + native_to_global_joint_name: dict[str, str] + + +@dataclass +class RoboPlanContext: + """DimOS context wrapper for RoboPlan world state.""" + + q_by_robot: dict[WorldRobotID, NDArray[np.float64]] = field(default_factory=dict) + + +class RoboPlanWorld: + """WorldSpec implementation backed by RoboPlan scene and collision queries.""" + + def __init__( + self, + enable_viz: bool = False, + max_generated_composite_groups: int = 128, + selected_start_tolerance: float = 1e-6, + **_: Any, + ) -> None: + if max_generated_composite_groups < 0: + raise ValueError("max_generated_composite_groups must be non-negative") + if selected_start_tolerance < 0.0: + raise ValueError("selected_start_tolerance must be non-negative") + self._scene: Any | None = None + self._enable_viz = enable_viz + self._max_generated_composite_groups = max_generated_composite_groups + self._selected_start_tolerance = selected_start_tolerance + if enable_viz: + logger.warning("RoboPlanWorld does not currently provide manipulation visualization") + + self._robots: dict[WorldRobotID, _RoboPlanRobotData] = {} + self._obstacles: dict[str, Obstacle] = {} + self._obstacle_handles: dict[str, Any] = {} + self._robot_counter = 0 + self._finalized = False + self._live_context = RoboPlanContext() + self._srdf_tempdirs: list[tempfile.TemporaryDirectory[str]] = [] + self._planning_groups = PlanningGroupRegistry() + self._robot_ids_by_name: dict[RobotName, WorldRobotID] = {} + self._roboplan_native_joint_names: dict[PlanningGroupID, tuple[str, ...]] = {} + self._native_names_by_robot: dict[RobotName, _NativeNameMap] = {} + self._movable_native_joint_names_by_robot: dict[RobotName, tuple[str, ...]] = {} + self._roboplan_groups_by_selection: dict[ + frozenset[PlanningGroupID], _RoboPlanGroupData + ] = {} + self._full_native_joint_names: tuple[str, ...] = () + self._uses_composite_model = False + + # Robot Management + + def add_robot(self, config: RobotModelConfig) -> WorldRobotID: + """Register a supported robot model for later RoboPlan scene construction.""" + if self._finalized: + raise RuntimeError("Cannot add robot after world is finalized") + if config.name in self._robot_ids_by_name: + raise ValueError(f"Robot name '{config.name}' is already registered") + if not Path(config.model_path).exists(): + raise FileNotFoundError(f"Robot model not found: {Path(config.model_path).resolve()}") + + self._validate_robot_config(config) + self._robot_counter += 1 + robot_id = f"robot_{self._robot_counter}" + model_handle = config.name + limits = self._configured_joint_limits(config) + lower, upper = limits if limits is not None else (None, None) + self._robots[robot_id] = _RoboPlanRobotData( + robot_id=robot_id, + config=config, + lower_limits=lower, + upper_limits=upper, + model_handle=model_handle, + ) + self._live_context.q_by_robot[robot_id] = np.zeros( + len(config.joint_names), dtype=np.float64 + ) + self._planning_groups.add_robot(config) + self._robot_ids_by_name[config.name] = robot_id + logger.info(f"Registered RoboPlan robot '{robot_id}' ({config.name})") + return robot_id + + def get_robot_ids(self) -> list[WorldRobotID]: + """Get all robot IDs in the world.""" + return list(self._robots.keys()) + + def get_robot_config(self, robot_id: WorldRobotID) -> RobotModelConfig: + """Get robot configuration by ID.""" + return self._get_robot(robot_id).config + + def get_joint_limits( + self, robot_id: WorldRobotID + ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Get joint limits in DimOS joint order.""" + robot = self._get_robot(robot_id) + if robot.lower_limits is None or robot.upper_limits is None: + raise RuntimeError("RoboPlan joint limits are unavailable until world is finalized") + return robot.lower_limits.copy(), robot.upper_limits.copy() + + # Obstacle Management + + def add_obstacle(self, obstacle: Obstacle) -> str: + """Add a supported obstacle to the RoboPlan scene.""" + self._require_finalized() + obstacle_id = obstacle.name + if obstacle_id in self._obstacles: + return obstacle_id + handle = self._add_obstacle_to_scene(obstacle, obstacle_id) + self._obstacles[obstacle_id] = obstacle + self._obstacle_handles[obstacle_id] = handle + return obstacle_id + + def remove_obstacle(self, obstacle_id: str) -> bool: + """Remove an obstacle from the RoboPlan scene.""" + self._require_finalized() + if obstacle_id not in self._obstacles: + return False + handle = self._obstacle_handles.get(obstacle_id, obstacle_id) + scene = self._require_scene() + scene.removeGeometry(handle) + del self._obstacles[obstacle_id] + self._obstacle_handles.pop(obstacle_id, None) + return True + + def update_obstacle_pose(self, obstacle_id: str, pose: PoseStamped) -> bool: + """Update an obstacle pose and invalidate collision scratch.""" + self._require_finalized() + if obstacle_id not in self._obstacles: + return False + handle = self._obstacle_handles.get(obstacle_id, obstacle_id) + scene = self._require_scene() + scene.updateGeometryPlacement(handle, pose_to_matrix(pose)) + self._obstacles[obstacle_id] = replace(self._obstacles[obstacle_id], pose=pose) + return True + + def clear_obstacles(self) -> None: + """Remove all tracked obstacles.""" + for obstacle_id in list(self._obstacles.keys()): + self.remove_obstacle(obstacle_id) + + def get_obstacles(self) -> list[Obstacle]: + """Get all obstacles currently tracked by DimOS.""" + return list(self._obstacles.values()) + + # Lifecycle + + def finalize(self) -> None: + """Construct the RoboPlan scene and mark it ready for planning queries. + + RoboPlan Python bindings construct a query-ready Scene directly; v0.4.0 + exposes no Scene.finalize() lifecycle method. + """ + if self._finalized: + return + if not self._robots: + raise RuntimeError("Cannot finalize RoboPlanWorld before adding a robot") + self._uses_composite_model = len(self._robots) > 1 + self._scene = self._create_scene() + native_joint_names = self._validate_planning_group_metadata(self._scene) + for robot_id, robot in tuple(self._robots.items()): + lower, upper = self._extract_joint_limits(robot.config, robot.model_handle) + self._robots[robot_id] = replace( + robot, + lower_limits=lower, + upper_limits=upper, + ) + self._roboplan_native_joint_names.update(native_joint_names) + self._finalized = True + + @property + def is_finalized(self) -> bool: + """Check whether the scene is finalized.""" + return self._finalized + + # Context Management + + def get_live_context(self) -> RoboPlanContext: + """Get the live context that mirrors robot state.""" + self._require_finalized() + return self._live_context + + @contextmanager + def scratch_context(self) -> Generator[RoboPlanContext, None, None]: + """Create a per-consumer context with independent collision scratch.""" + self._require_finalized() + ctx = RoboPlanContext( + q_by_robot={robot_id: q.copy() for robot_id, q in self._live_context.q_by_robot.items()} + ) + yield ctx + + def sync_from_joint_state(self, robot_id: WorldRobotID, joint_state: JointState) -> None: + """Sync live context from a driver joint-state message.""" + if not self._finalized: + return + self.set_joint_state(self._live_context, robot_id, joint_state) + + # State Operations + + def set_joint_state( + self, ctx: RoboPlanContext, robot_id: WorldRobotID, joint_state: JointState + ) -> None: + """Set robot joint state in a context.""" + self._require_finalized() + ctx.q_by_robot[robot_id] = self._joint_state_to_q(robot_id, joint_state) + + def get_joint_state(self, ctx: RoboPlanContext, robot_id: WorldRobotID) -> JointState: + """Get robot joint state from a context.""" + robot = self._get_robot(robot_id) + q = ctx.q_by_robot.get(robot_id) + if q is None: + q = np.zeros(len(robot.config.joint_names), dtype=np.float64) + return JointState(name=robot.config.joint_names, position=q.astype(float).tolist()) + + # Collision Checking + + def is_collision_free(self, ctx: RoboPlanContext, robot_id: WorldRobotID) -> bool: + """Check if the robot configuration in a context is collision-free.""" + self._require_finalized() + q = ctx.q_by_robot.get(robot_id) + if q is None: + raise KeyError(f"Robot '{robot_id}' not found in context") + return not self._has_collisions(robot_id, q, ctx) + + def get_min_distance(self, ctx: RoboPlanContext, robot_id: WorldRobotID) -> float: + """Get minimum signed distance. + + RoboPlan signed-distance semantics are not verified yet, so do not return + a misleading approximation. + """ + raise NotImplementedError("RoboPlanWorld.get_min_distance is not implemented") + + def check_config_collision_free(self, robot_id: WorldRobotID, joint_state: JointState) -> bool: + """Check a joint state using a scratch collision context.""" + with self.scratch_context() as ctx: + self.set_joint_state(ctx, robot_id, joint_state) + return self.is_collision_free(ctx, robot_id) + + def check_edge_collision_free( + self, + robot_id: WorldRobotID, + start: JointState, + end: JointState, + step_size: float = 0.05, + ) -> bool: + """Check if an interpolated edge is collision-free.""" + q_start = self._joint_state_to_q(robot_id, start) + q_end = self._joint_state_to_q(robot_id, end) + with self.scratch_context() as ctx: + return not self._call_path_collision_checker(ctx, robot_id, q_start, q_end, step_size) + + # Forward Kinematics + + def get_group_ee_pose(self, ctx: RoboPlanContext, group_id: PlanningGroupID) -> PoseStamped: + """Get pose for a planning group's target frame.""" + self._require_finalized() + group, robot_id, robot, _native_joint_names = self._resolve_group(group_id) + if group.tip_link is None: + raise ValueError(f"Planning group '{group_id}' has no pose target frame") + q = ctx.q_by_robot.get(robot_id) + if q is None: + raise KeyError(f"Robot '{robot_id}' not found in context") + scene = self._require_scene() + result = scene.forwardKinematics( + self._full_scene_q(ctx), self._native_link_name(robot.config, group.tip_link), "" + ) + mat = np.asarray(result, dtype=np.float64) + pose = matrix_to_pose(mat) + return PoseStamped( + frame_id="world", + position=[pose.position.x, pose.position.y, pose.position.z], + orientation=[ + pose.orientation.x, + pose.orientation.y, + pose.orientation.z, + pose.orientation.w, + ], + ) + + def get_link_pose( + self, ctx: RoboPlanContext, robot_id: WorldRobotID, link_name: str + ) -> NDArray[np.float64]: + """Get link pose as a 4x4 homogeneous transform.""" + self._require_finalized() + q = ctx.q_by_robot.get(robot_id) + if q is None: + raise KeyError(f"Robot '{robot_id}' not found in context") + scene = self._require_scene() + robot = self._get_robot(robot_id) + result = scene.forwardKinematics( + self._full_scene_q(ctx), self._native_link_name(robot.config, link_name), "" + ) + return np.asarray(result, dtype=np.float64) + + def get_group_jacobian( + self, ctx: RoboPlanContext, group_id: PlanningGroupID + ) -> NDArray[np.float64]: + """Get target-frame Jacobian with columns in public group local joint order.""" + self._require_finalized() + group, robot_id, robot, native_joint_names = self._resolve_group(group_id) + if group.tip_link is None: + raise ValueError(f"Planning group '{group_id}' has no pose target frame") + q = ctx.q_by_robot.get(robot_id) + if q is None: + raise KeyError(f"Robot '{robot_id}' not found in context") + scene = self._require_scene() + result = scene.computeFrameJacobian( + self._full_scene_q(ctx), self._native_link_name(robot.config, group.tip_link), True + ) + arr = np.asarray(result, dtype=np.float64) + if arr.shape[0] != 6: + raise ValueError(f"Unexpected RoboPlan Jacobian shape: {arr.shape}; expected 6 x n") + return self._reorder_jacobian_columns( + group, robot.config.joint_names, native_joint_names, arr + ) + + # PlannerSpec for native RoboPlan planning + + def plan_selected_joint_path( + self, + world: Any, + selection: PlanningGroupSelection, + start: JointState, + goal: JointState, + timeout: float = 10.0, + ) -> PlanningResult: + """Plan a path using RoboPlan-native RRT for one selected planning group.""" + if world is not self: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan-native planner requires its RoboPlanWorld instance", + ) + self._require_finalized() + unsupported = self._validate_supported_selection(selection) + if unsupported is not None: + return unsupported + + start_time = time.time() + group_data = self._group_data_for_selection_ids(selection.group_ids) + try: + invalid_start = self._validate_selected_start_matches_belief(selection, start) + if invalid_start is not None: + return invalid_start + q_start = self._joint_state_to_native_selection_q(selection, group_data, start) + q_goal = self._joint_state_to_native_selection_q(selection, group_data, goal) + self._set_scene_current_q(self._live_context) + result = self._run_native_rrt( + group_data.group_name, group_data.native_joint_names, q_start, q_goal, timeout + ) + result_names, path_arrays = self._extract_native_path(result) + path = self._native_path_to_public_selection_path( + selection, group_data, path_arrays, result_names + ) + if path and not self._selection_path_collision_free(selection, path): + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message="RoboPlan-native planning failed: returned path is in collision", + ) + except ValueError as exc: + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + planning_time=time.time() - start_time, + message=str(exc), + ) + except Exception as exc: + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message=f"RoboPlan-native planning failed: {exc}", + ) + if not path: + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message="RoboPlan-native planning failed: returned an empty path", + ) + return PlanningResult( + status=PlanningStatus.SUCCESS, + path=path, + planning_time=time.time() - start_time, + path_length=compute_path_length(path), + message="RoboPlan path found", + ) + + def get_name(self) -> str: + """Get planner name.""" + return "RoboPlan" + + # Internals + + def _create_scene(self) -> Any: + if self._uses_composite_model: + urdf_path, srdf_path, package_paths = self._prepare_composite_model() + scene_name = "dimos_composite" + else: + robot = next(iter(self._robots.values())) + urdf_path = self._prepare_robot_urdf(robot.config) + scene_name = robot.config.name + self._register_identity_native_names(robot.config, urdf_path) + self._register_roboplan_groups() + srdf_path = self._prepare_single_robot_srdf(robot.config, urdf_path) + package_paths = [str(path) for path in robot.config.package_paths.values()] + scene = roboplan_core.Scene(scene_name, str(urdf_path), str(srdf_path), package_paths) + if self._uses_composite_model: + self._apply_collision_exclusions_from_srdf(scene, srdf_path) + else: + robot = next(iter(self._robots.values())) + self._apply_collision_exclusions(scene, robot.config, urdf_path) + return scene + + def _validate_robot_config(self, config: RobotModelConfig) -> None: + if not config.joint_names: + raise ValueError("RoboPlanWorld requires explicit joint_names") + + def _prepare_robot_urdf(self, config: RobotModelConfig) -> Path: + return Path( + prepare_urdf_for_drake( + config.model_path, + package_paths=config.package_paths, + xacro_args=config.xacro_args, + convert_meshes=config.auto_convert_meshes, + strip_world_joint_child_link=config.base_link + if config.strip_model_world_joint + else None, + ) + ) + + def _prepare_single_robot_srdf(self, config: RobotModelConfig, urdf_path: Path) -> Path: + if config.srdf_path is not None: + srdf_path = Path(config.srdf_path) + if not srdf_path.exists(): + raise FileNotFoundError(f"Robot SRDF not found: {srdf_path.resolve()}") + return srdf_path + + srdf = self._generate_srdf(config, urdf_path) + srdf_tempdir = tempfile.TemporaryDirectory(prefix="dimos_roboplan_srdf_") + self._srdf_tempdirs.append(srdf_tempdir) + cache_dir = Path(srdf_tempdir.name) + srdf_path = cache_dir / f"{config.name}.srdf" + srdf_path.write_text(srdf) + return srdf_path + + def _generate_srdf(self, config: RobotModelConfig, urdf_path: Path) -> str: + lines = [f''] + if self._roboplan_groups_by_selection: + group_items = [ + (group_data.group_name, group_data.native_joint_names) + for group_data in self._roboplan_groups_by_selection.values() + ] + else: + group_items = [ + (group.name, tuple(group.joint_names)) for group in config.planning_groups + ] + for group_name, joint_names in group_items: + lines.append(f' ') + for joint_name in joint_names: + lines.append(f' ') + lines.append(" ") + for link1, link2 in self._collision_exclusion_pairs(config, urdf_path): + lines.append( + f' ' + ) + lines.append("") + return "\n".join(lines) + "\n" + + def _prepare_composite_model(self) -> tuple[Path, Path, list[str]]: + tempdir = tempfile.TemporaryDirectory(prefix="dimos_roboplan_composite_") + self._srdf_tempdirs.append(tempdir) + cache_dir = Path(tempdir.name) + prepared_urdfs: dict[RobotName, Path] = {} + root = ET.Element("robot", {"name": "dimos_composite"}) + ET.SubElement(root, "link", {"name": "dimos_world"}) + + for robot in self._robots.values(): + prepared_urdf = self._prepare_robot_urdf(robot.config) + prepared_urdfs[robot.config.name] = prepared_urdf + native_names = self._rewrite_robot_urdf_into_composite( + root, robot.config, prepared_urdf + ) + self._native_names_by_robot[robot.config.name] = native_names + + self._register_roboplan_groups() + urdf_path = cache_dir / "dimos_composite.urdf" + ET.ElementTree(root).write(urdf_path, encoding="unicode", xml_declaration=True) + srdf_path = cache_dir / "dimos_composite.srdf" + srdf_path.write_text(self._generate_composite_srdf(prepared_urdfs)) + package_paths = self._composite_package_paths() + return urdf_path, srdf_path, package_paths + + def _rewrite_robot_urdf_into_composite( + self, composite_root: ET.Element, config: RobotModelConfig, urdf_path: Path + ) -> _NativeNameMap: + try: + robot_root = ET.parse(urdf_path).getroot() + except ET.ParseError as exc: + raise ValueError( + f"Unable to parse prepared URDF for composite model: {urdf_path}" + ) from exc + + local_links = [name for link in robot_root.findall("link") if (name := link.get("name"))] + local_joints = [ + name for joint in robot_root.findall("joint") if (name := joint.get("name")) + ] + local_movable_joints = [ + name + for joint in robot_root.findall("joint") + if (name := joint.get("name")) and self._is_scene_position_joint(joint) + ] + local_materials = [ + name for material in robot_root.findall(".//material") if (name := material.get("name")) + ] + if len(set(local_links)) != len(local_links): + raise ValueError(f"Robot '{config.name}' URDF has duplicate link names") + if len(set(local_joints)) != len(local_joints): + raise ValueError(f"Robot '{config.name}' URDF has duplicate joint names") + + link_map = {name: self._native_name(config.name, name) for name in local_links} + joint_map = {name: self._native_name(config.name, name) for name in local_joints} + material_map = {name: self._native_name(config.name, name) for name in local_materials} + for joint_name in config.joint_names: + joint_map.setdefault(joint_name, self._native_name(config.name, joint_name)) + if joint_name not in local_movable_joints: + local_movable_joints.append(joint_name) + for link_name in (config.base_link, config.end_effector_link): + if link_name: + link_map.setdefault(link_name, self._native_name(config.name, link_name)) + self._movable_native_joint_names_by_robot[config.name] = tuple( + joint_map[name] for name in local_movable_joints + ) + + for child in list(robot_root): + rewritten = deepcopy(child) + self._rewrite_urdf_names(rewritten, link_map, joint_map, material_map) + composite_root.append(rewritten) + + if config.base_link not in link_map: + raise ValueError( + f"Robot '{config.name}' base_link '{config.base_link}' was not found in prepared URDF" + ) + fixed_joint = ET.SubElement( + composite_root, + "joint", + {"name": self._native_name(config.name, "dimos_world_joint"), "type": "fixed"}, + ) + ET.SubElement(fixed_joint, "parent", {"link": "dimos_world"}) + ET.SubElement(fixed_joint, "child", {"link": link_map[config.base_link]}) + ET.SubElement(fixed_joint, "origin", self._pose_origin_attrs(config.base_pose)) + return _NativeNameMap(config.name, joint_map, link_map) + + def _rewrite_urdf_names( + self, + element: ET.Element, + link_map: dict[str, str], + joint_map: dict[str, str], + material_map: dict[str, str], + ) -> None: + if element.tag == "link" and (name := element.get("name")) in link_map: + element.set("name", link_map[name]) + elif element.tag == "joint" and (name := element.get("name")) in joint_map: + element.set("name", joint_map[name]) + elif element.tag == "material" and (name := element.get("name")) in material_map: + element.set("name", material_map[name]) + + for attr_name, name_map in (("link", link_map), ("joint", joint_map)): + attr_value = element.get(attr_name) + if attr_value in name_map: + element.set(attr_name, name_map[attr_value]) + for child in list(element): + self._rewrite_urdf_names(child, link_map, joint_map, material_map) + + def _pose_origin_attrs(self, pose: PoseStamped) -> dict[str, str]: + mat = pose_to_matrix(pose) + xyz = mat[:3, 3] + rpy = self._matrix_to_rpy(mat[:3, :3]) + return { + "xyz": " ".join(f"{float(value):.17g}" for value in xyz), + "rpy": " ".join(f"{float(value):.17g}" for value in rpy), + } + + def _matrix_to_rpy(self, rotation: NDArray[np.float64]) -> tuple[float, float, float]: + sy = sqrt(rotation[0, 0] * rotation[0, 0] + rotation[1, 0] * rotation[1, 0]) + if sy > 1e-9: + return ( + atan2(rotation[2, 1], rotation[2, 2]), + atan2(-rotation[2, 0], sy), + atan2(rotation[1, 0], rotation[0, 0]), + ) + return (atan2(-rotation[1, 2], rotation[1, 1]), atan2(-rotation[2, 0], sy), 0.0) + + def _register_identity_native_names(self, config: RobotModelConfig, urdf_path: Path) -> None: + try: + root = ET.parse(urdf_path).getroot() + except ET.ParseError as exc: + raise ValueError( + f"Unable to parse prepared URDF for RoboPlan model: {urdf_path}" + ) from exc + link_names = {name for link in root.findall("link") if (name := link.get("name"))} + joint_names = {name for joint in root.findall("joint") if (name := joint.get("name"))} + movable_joint_names = [ + name + for joint in root.findall("joint") + if (name := joint.get("name")) and self._is_scene_position_joint(joint) + ] + for joint_name in config.joint_names: + if joint_name not in movable_joint_names: + movable_joint_names.append(joint_name) + self._native_names_by_robot[config.name] = _NativeNameMap( + robot_name=config.name, + joint_names={name: name for name in sorted(joint_names | set(config.joint_names))}, + link_names={ + name: name + for name in sorted(link_names | {config.base_link, config.end_effector_link or ""}) + if name + }, + ) + self._movable_native_joint_names_by_robot[config.name] = tuple(movable_joint_names) + + def _is_scene_position_joint(self, joint: ET.Element) -> bool: + return joint.get("type") != "fixed" and joint.find("mimic") is None + + def _register_roboplan_groups(self) -> None: + self._roboplan_groups_by_selection.clear() + self._roboplan_native_joint_names.clear() + groups = self._planning_groups.list() + for group in groups: + group_data = self._make_roboplan_group_data((group,)) + self._roboplan_groups_by_selection[frozenset(group_data.group_ids)] = group_data + self._roboplan_native_joint_names[group.id] = group_data.native_joint_names + + composite_count = 0 + for size in range(2, len(groups) + 1): + for group_tuple in combinations(groups, size): + if not self._groups_are_non_overlapping(group_tuple): + continue + composite_count += 1 + if composite_count > self._max_generated_composite_groups: + raise ValueError( + "Generated RoboPlan composite planning-group count exceeds " + f"max_generated_composite_groups={self._max_generated_composite_groups}" + ) + group_data = self._make_roboplan_group_data(group_tuple) + self._roboplan_groups_by_selection[frozenset(group_data.group_ids)] = group_data + + self._full_native_joint_names = tuple( + native_joint_name + for robot in self._robots.values() + for native_joint_name in self._movable_native_joint_names_by_robot.get( + robot.config.name, + tuple( + self._native_names_by_robot[robot.config.name].joint(joint_name) + for joint_name in robot.config.joint_names + ), + ) + ) + + def _make_roboplan_group_data(self, groups: tuple[PlanningGroup, ...]) -> _RoboPlanGroupData: + native_joint_names: list[str] = [] + native_to_global: dict[str, str] = {} + for group in groups: + native_map = self._native_names_by_robot[group.robot_name] + for local_name, global_name in zip( + group.local_joint_names, group.joint_names, strict=True + ): + native_name = native_map.joint(local_name) + if native_name in native_to_global: + raise ValueError( + f"Duplicate RoboPlan-native joint name in planning groups: {native_name}" + ) + native_joint_names.append(native_name) + native_to_global[native_name] = global_name + group_ids = tuple(group.id for group in groups) + if len(groups) == 1 and not self._uses_composite_model: + group_name = groups[0].group_name + else: + group_name = self._roboplan_group_name(group_ids) + return _RoboPlanGroupData( + group_ids=group_ids, + group_name=group_name, + native_joint_names=tuple(native_joint_names), + native_to_global_joint_name=native_to_global, + ) + + def _groups_are_non_overlapping(self, groups: tuple[PlanningGroup, ...]) -> bool: + seen: set[str] = set() + for group in groups: + overlap = seen.intersection(group.joint_names) + if overlap: + return False + seen.update(group.joint_names) + return True + + def _generate_composite_srdf(self, prepared_urdfs: dict[RobotName, Path]) -> str: + lines = [''] + for group_data in self._roboplan_groups_by_selection.values(): + lines.append(f' ') + for native_joint_name in group_data.native_joint_names: + lines.append(f' ') + lines.append(" ") + for link1, link2 in self._composite_collision_exclusion_pairs(prepared_urdfs): + lines.append( + f' ' + ) + lines.append("") + return "\n".join(lines) + "\n" + + def _composite_collision_exclusion_pairs( + self, prepared_urdfs: dict[RobotName, Path] + ) -> list[tuple[str, str]]: + pairs: set[tuple[str, str]] = set() + for robot in self._robots.values(): + native_map = self._native_names_by_robot[robot.config.name] + local_pairs = set(robot.config.collision_exclusion_pairs) + local_pairs.update( + self._adjacent_link_pairs_from_urdf(prepared_urdfs[robot.config.name]) + ) + local_pairs.update(self._disable_collision_pairs_from_srdf(robot.config.srdf_path)) + for link1, link2 in local_pairs: + try: + native_pair = tuple(sorted((native_map.link(link1), native_map.link(link2)))) + except KeyError: + logger.debug( + f"Skipping unmapped RoboPlan collision exclusion pair: {robot.config.name}:" + f" {link1} <-> {link2}" + ) + continue + pairs.add(native_pair) # type: ignore[arg-type] + return sorted(pairs) + + def _disable_collision_pairs_from_srdf(self, srdf_path: Path | None) -> list[tuple[str, str]]: + if srdf_path is None: + return [] + try: + root = ET.parse(srdf_path).getroot() + except (ET.ParseError, FileNotFoundError): + return [] + pairs: list[tuple[str, str]] = [] + for disable in root.findall("disable_collisions"): + link1 = disable.get("link1") + link2 = disable.get("link2") + if link1 and link2: + pairs.append((link1, link2)) + return pairs + + def _apply_collision_exclusions_from_srdf(self, scene: Any, srdf_path: Path) -> None: + for link1, link2 in self._disable_collision_pairs_from_srdf(srdf_path): + try: + scene.setCollisions(link1, link2, False) + except RuntimeError: + logger.debug( + f"RoboPlan did not accept collision exclusion pair: {link1} <-> {link2}" + ) + except AttributeError: + return + + def _composite_package_paths(self) -> list[str]: + paths: list[str] = [] + for robot in self._robots.values(): + for path in robot.config.package_paths.values(): + path_text = str(path) + if path_text not in paths: + paths.append(path_text) + return paths + + def _native_name(self, robot_name: RobotName, local_name: str) -> str: + return f"{self._safe_name(robot_name)}{_NATIVE_NAME_SEPARATOR}{self._safe_name(local_name)}" + + def _roboplan_group_name(self, group_ids: Iterable[PlanningGroupID]) -> str: + return ( + _COMPOSITE_GROUP_PREFIX + + "__" + + "__".join(self._safe_name(group_id) for group_id in group_ids) + ) + + def _safe_name(self, value: str) -> str: + return value.replace("/", "_").replace(":", "_").replace(" ", "_") + + def _validate_planning_group_metadata( + self, + scene: Any, + ) -> dict[PlanningGroupID, tuple[str, ...]]: + native_joint_names: dict[PlanningGroupID, tuple[str, ...]] = {} + updated_group_data: dict[frozenset[PlanningGroupID], _RoboPlanGroupData] = {} + for selection_key, group_data in self._roboplan_groups_by_selection.items(): + try: + group_info = scene.getJointGroupInfo(group_data.group_name) + except AttributeError as exc: + raise ValueError("RoboPlan scene does not expose planning group metadata") from exc + native_names = tuple(group_info.joint_names) + if len(set(native_names)) != len(native_names): + raise ValueError( + f"RoboPlan returned duplicate joint names for planning group '{group_data.group_name}'" + ) + if set(native_names) != set(group_data.native_joint_names): + raise ValueError( + "RoboPlan planning group joint names do not match configured " + f"planning group '{group_data.group_name}': RoboPlan={list(native_names)}, " + f"configured={list(group_data.native_joint_names)}" + ) + updated_group_data[selection_key] = replace(group_data, native_joint_names=native_names) + if len(group_data.group_ids) == 1: + native_joint_names[group_data.group_ids[0]] = native_names + self._roboplan_groups_by_selection.update(updated_group_data) + return native_joint_names + + def _collision_exclusion_pairs( + self, config: RobotModelConfig, urdf_path: Path + ) -> list[tuple[str, str]]: + pairs = set(config.collision_exclusion_pairs) + pairs.update(self._adjacent_link_pairs_from_urdf(urdf_path)) + return sorted(pairs) + + def _adjacent_link_pairs_from_urdf(self, urdf_path: Path) -> list[tuple[str, str]]: + try: + root = ET.parse(urdf_path).getroot() + except ET.ParseError as exc: + raise ValueError( + f"Unable to parse prepared URDF for SRDF generation: {urdf_path}" + ) from exc + + pairs: list[tuple[str, str]] = [] + for joint in root.findall("joint"): + parent = joint.find("parent") + child = joint.find("child") + parent_link = parent.get("link") if parent is not None else None + child_link = child.get("link") if child is not None else None + if parent_link and child_link: + pairs.append((parent_link, child_link)) + return pairs + + def _apply_collision_exclusions( + self, scene: Any, config: RobotModelConfig, urdf_path: Path + ) -> None: + for link1, link2 in self._collision_exclusion_pairs(config, urdf_path): + try: + scene.setCollisions(link1, link2, False) + except RuntimeError: + logger.debug( + f"RoboPlan did not accept collision exclusion pair: {link1} <-> {link2}" + ) + except AttributeError: + return + + def _extract_joint_limits( + self, config: RobotModelConfig, model_handle: Any + ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + configured_limits = self._configured_joint_limits(config) + if configured_limits is not None: + lower, upper = configured_limits + else: + limits = self._query_scene_joint_limits(config, model_handle) + if limits is None: + raise ValueError( + "RoboPlanWorld requires explicit joint_limits_lower/joint_limits_upper " + "when limits cannot be read from RoboPlan bindings" + ) + lower, upper = limits + if len(lower) != len(config.joint_names) or len(upper) != len(config.joint_names): + raise ValueError("Joint limit length must match joint_names length") + if np.any(~np.isfinite(lower)) or np.any(~np.isfinite(upper)): + raise ValueError("RoboPlanWorld requires finite joint limits") + return lower, upper + + def _configured_joint_limits( + self, config: RobotModelConfig + ) -> tuple[NDArray[np.float64], NDArray[np.float64]] | None: + if config.joint_limits_lower is None or config.joint_limits_upper is None: + return None + lower = np.asarray(config.joint_limits_lower, dtype=np.float64) + upper = np.asarray(config.joint_limits_upper, dtype=np.float64) + if len(lower) != len(config.joint_names) or len(upper) != len(config.joint_names): + raise ValueError("Joint limit length must match joint_names length") + if np.any(~np.isfinite(lower)) or np.any(~np.isfinite(upper)): + raise ValueError("RoboPlanWorld requires finite joint limits") + return lower, upper + + def _query_scene_joint_limits( + self, config: RobotModelConfig, model_handle: Any + ) -> tuple[NDArray[np.float64], NDArray[np.float64]] | None: + _ = model_handle + scene = self._require_scene() + joint_order = self._query_scene_joint_limit_order(scene, config) + if joint_order is None: + return None + group_data = self._joint_limit_group_data(config) + lower, upper = scene.getPositionLimitVectors(group_data.group_name, False) + lower_array = np.asarray(lower, dtype=np.float64) + upper_array = np.asarray(upper, dtype=np.float64) + if len(joint_order) != len(lower_array) or len(joint_order) != len(upper_array): + raise ValueError( + "RoboPlan joint limit order length does not match returned limit vectors" + ) + if len(set(joint_order)) != len(joint_order): + raise ValueError("RoboPlan returned duplicate joint names for joint limits") + native_map = self._native_names_by_robot[config.name] + configured_native_names = [ + native_map.joint(joint_name) for joint_name in config.joint_names + ] + if set(joint_order) != set(configured_native_names): + raise ValueError( + "RoboPlan joint limit names do not match configured joint_names: " + f"RoboPlan={joint_order}, configured={configured_native_names}" + ) + order_indices = [joint_order.index(joint_name) for joint_name in configured_native_names] + return lower_array[order_indices], upper_array[order_indices] + + def _query_scene_joint_limit_order( + self, scene: Any, config: RobotModelConfig + ) -> list[str] | None: + try: + group_info = scene.getJointGroupInfo(self._joint_limit_group_data(config).group_name) + except AttributeError: + return None + return list(group_info.joint_names) + + def _joint_limit_group_data(self, config: RobotModelConfig) -> _RoboPlanGroupData: + for group in self._planning_groups.groups_for_robot(config.name): + if set(group.local_joint_names) == set(config.joint_names): + return self._group_data_for_selection_ids((group.id,)) + raise ValueError( + "RoboPlanWorld requires explicit joint_limits_lower/joint_limits_upper when no " + "configured planning group covers RobotModelConfig.joint_names" + ) + + def _get_robot(self, robot_id: WorldRobotID) -> _RoboPlanRobotData: + if robot_id not in self._robots: + raise KeyError(f"Robot '{robot_id}' not found") + return self._robots[robot_id] + + def _joint_state_to_q( + self, robot_id: WorldRobotID, joint_state: JointState + ) -> NDArray[np.float64]: + robot = self._get_robot(robot_id) + if len(joint_state.position) != len(robot.config.joint_names): + raise ValueError("JointState position length must match configured joint count") + if not joint_state.name: + return np.asarray(joint_state.position, dtype=np.float64) + name_to_pos: dict[str, float] = {} + for name, position in zip(joint_state.name, joint_state.position, strict=True): + local_name = self._to_robot_local_joint_name(robot.config, name) + if local_name in name_to_pos: + raise ValueError( + f"JointState contains duplicate joint for RoboPlanWorld: {local_name}" + ) + name_to_pos[local_name] = float(position) + missing = [name for name in robot.config.joint_names if name not in name_to_pos] + if missing: + raise ValueError(f"JointState missing joints for RoboPlanWorld: {missing}") + return np.asarray( + [name_to_pos[name] for name in robot.config.joint_names], dtype=np.float64 + ) + + def _to_robot_local_joint_name(self, config: RobotModelConfig, name: str) -> str: + """Map accepted robot-scoped joint-state names to local model names.""" + if name in config.joint_names: + return name + prefix = f"{config.name}/" + if name.startswith(prefix): + local_name = name.removeprefix(prefix) + if local_name in config.joint_names: + return local_name + return name + + def _resolve_group( + self, group_id: PlanningGroupID + ) -> tuple[PlanningGroup, WorldRobotID, _RoboPlanRobotData, tuple[str, ...]]: + group = self._planning_groups.get(group_id) + try: + robot_id = self._robot_ids_by_name[group.robot_name] + except KeyError as exc: + raise KeyError(f"Robot for planning group '{group_id}' is not registered") from exc + robot = self._get_robot(robot_id) + try: + native_joint_names = self._roboplan_native_joint_names[group.id] + except KeyError as exc: + raise KeyError( + f"RoboPlan native order for planning group '{group_id}' is missing" + ) from exc + return group, robot_id, robot, native_joint_names + + def _validate_supported_selection( + self, selection: PlanningGroupSelection + ) -> PlanningResult | None: + try: + canonical_groups = tuple( + self._planning_groups.get(group.id) for group in selection.groups + ) + except KeyError as exc: + return PlanningResult(status=PlanningStatus.UNSUPPORTED, message=str(exc)) + expected_joint_names = tuple( + joint_name for group in canonical_groups for joint_name in group.joint_names + ) + if tuple(selection.joint_names) != expected_joint_names: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message=( + "RoboPlan-native planning requires the selection joints to exactly match " + "the selected planning groups" + ), + ) + if frozenset(selection.group_ids) not in self._roboplan_groups_by_selection: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan-native planning has no generated group for this selection", + ) + return None + + def _group_data_for_selection_ids( + self, group_ids: Iterable[PlanningGroupID] + ) -> _RoboPlanGroupData: + key = frozenset(group_ids) + try: + return self._roboplan_groups_by_selection[key] + except KeyError as exc: + raise KeyError(f"No RoboPlan group generated for selection {sorted(key)}") from exc + + def _project_robot_q_to_native_group( + self, + group: PlanningGroup, + robot: _RoboPlanRobotData, + native_joint_names: tuple[str, ...], + q: NDArray[np.float64], + ) -> NDArray[np.float64]: + if len(q) != len(robot.config.joint_names): + raise ValueError( + f"Robot state for '{robot.robot_id}' has {len(q)} positions, " + f"expected {len(robot.config.joint_names)}" + ) + native_map = self._native_names_by_robot[robot.config.name] + positions_by_name = { + native_map.joint(joint_name): position + for joint_name, position in zip(robot.config.joint_names, q, strict=True) + } + missing = [name for name in native_joint_names if name not in positions_by_name] + if missing: + raise ValueError( + f"Planning group '{group.id}' has joints outside robot state: {missing}" + ) + return np.asarray( + [positions_by_name[name] for name in native_joint_names], dtype=np.float64 + ) + + def _joint_state_to_native_group_q( + self, + group: PlanningGroup, + native_joint_names: tuple[str, ...], + joint_state: JointState, + ) -> NDArray[np.float64]: + global_state = joint_target_to_global_names(group, joint_state) + native_map = self._native_names_by_robot[group.robot_name] + positions_by_native_name = { + native_map.joint(local_name): position + for local_name, position in zip( + group.local_joint_names, global_state.position, strict=True + ) + } + missing = [name for name in native_joint_names if name not in positions_by_native_name] + if missing: + raise ValueError(f"JointState for '{group.id}' is missing joints: {missing}") + return np.asarray([positions_by_native_name[name] for name in native_joint_names]) + + def _joint_state_to_native_selection_q( + self, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + joint_state: JointState, + ) -> NDArray[np.float64]: + positions_by_global = self._selection_positions_by_global(selection, joint_state) + missing = [ + native_name + for native_name in group_data.native_joint_names + if group_data.native_to_global_joint_name[native_name] not in positions_by_global + ] + if missing: + raise ValueError(f"JointState for selection is missing native joints: {missing}") + return np.asarray( + [ + positions_by_global[group_data.native_to_global_joint_name[native_name]] + for native_name in group_data.native_joint_names + ], + dtype=np.float64, + ) + + def _selection_positions_by_global( + self, selection: PlanningGroupSelection, joint_state: JointState + ) -> dict[str, float]: + if len(joint_state.position) != len(selection.joint_names): + raise ValueError("JointState position length must match selected joint count") + if not joint_state.name: + return { + global_name: float(position) + for global_name, position in zip( + selection.joint_names, joint_state.position, strict=True + ) + } + if len(selection.groups) == 1: + group_state = joint_target_to_global_names(selection.groups[0], joint_state) + return dict(zip(group_state.name, group_state.position, strict=True)) + if not all(name in selection.joint_names for name in joint_state.name): + raise ValueError( + "Multi-group RoboPlan selections require JointState names in global selection order" + ) + positions_by_global: dict[str, float] = {} + for name, position in zip(joint_state.name, joint_state.position, strict=True): + if name in positions_by_global: + raise ValueError(f"JointState contains duplicate selected joint: {name}") + positions_by_global[name] = float(position) + missing = [name for name in selection.joint_names if name not in positions_by_global] + if missing: + raise ValueError(f"JointState missing selected joints: {missing}") + return positions_by_global + + def _validate_selected_start_matches_belief( + self, selection: PlanningGroupSelection, start: JointState + ) -> PlanningResult | None: + try: + start_by_global = self._selection_positions_by_global(selection, start) + belief_by_global = self._selection_belief_positions(selection) + except ValueError as exc: + return PlanningResult(status=PlanningStatus.INVALID_START, message=str(exc)) + for global_name in selection.joint_names: + if ( + abs(start_by_global[global_name] - belief_by_global[global_name]) + > self._selected_start_tolerance + ): + return PlanningResult( + status=PlanningStatus.INVALID_START, + message=( + "Selected start state does not match RoboPlan Planning world belief for " + f"{global_name}" + ), + ) + return None + + def _selection_belief_positions(self, selection: PlanningGroupSelection) -> dict[str, float]: + positions: dict[str, float] = {} + for group in selection.groups: + robot_id = self._robot_ids_by_name[group.robot_name] + robot = self._get_robot(robot_id) + robot_q = self._live_context.q_by_robot.get(robot_id) + if robot_q is None: + robot_q = np.zeros(len(robot.config.joint_names), dtype=np.float64) + local_positions = dict(zip(robot.config.joint_names, robot_q, strict=True)) + for local_name, global_name in zip( + group.local_joint_names, group.joint_names, strict=True + ): + positions[global_name] = float(local_positions[local_name]) + return positions + + def _selection_path_collision_free( + self, + selection: PlanningGroupSelection, + path: list[JointState], + step_size: float = 0.02, + ) -> bool: + if not path: + return False + if len(path) == 1: + return self._selection_config_collision_free(selection, path[0]) + for start, end in pairwise(path): + if not self._selection_edge_collision_free(selection, start, end, step_size): + return False + return True + + def _selection_config_collision_free( + self, selection: PlanningGroupSelection, joint_state: JointState + ) -> bool: + with self.scratch_context() as ctx: + self._set_selection_state(ctx, selection, joint_state) + return self._selected_robots_collision_free(ctx, selection) + + def _selection_edge_collision_free( + self, + selection: PlanningGroupSelection, + start: JointState, + end: JointState, + step_size: float, + ) -> bool: + start_by_global = self._selection_positions_by_global(selection, start) + end_by_global = self._selection_positions_by_global(selection, end) + q_start = np.asarray( + [start_by_global[name] for name in selection.joint_names], dtype=np.float64 + ) + q_end = np.asarray( + [end_by_global[name] for name in selection.joint_names], dtype=np.float64 + ) + dist = float(np.linalg.norm(q_end - q_start)) + if dist < 1e-8: + return self._selection_config_collision_free(selection, start) + n_steps = max(2, int(np.ceil(dist / step_size)) + 1) + with self.scratch_context() as ctx: + for i in range(n_steps): + t = i / (n_steps - 1) + q = q_start + t * (q_end - q_start) + self._set_selection_state( + ctx, + selection, + JointState(name=list(selection.joint_names), position=q.astype(float).tolist()), + ) + if not self._selected_robots_collision_free(ctx, selection): + return False + return True + + def _set_selection_state( + self, + ctx: RoboPlanContext, + selection: PlanningGroupSelection, + joint_state: JointState, + ) -> None: + positions_by_global = self._selection_positions_by_global(selection, joint_state) + for group in selection.groups: + robot_id = self._robot_ids_by_name[group.robot_name] + robot = self._get_robot(robot_id) + robot_q = ctx.q_by_robot.get(robot_id) + if robot_q is None: + robot_q = np.zeros(len(robot.config.joint_names), dtype=np.float64) + else: + robot_q = robot_q.copy() + index_by_local = {name: index for index, name in enumerate(robot.config.joint_names)} + for local_name, global_name in zip( + group.local_joint_names, group.joint_names, strict=True + ): + robot_q[index_by_local[local_name]] = positions_by_global[global_name] + ctx.q_by_robot[robot_id] = robot_q + + def _selected_robots_collision_free( + self, ctx: RoboPlanContext, selection: PlanningGroupSelection + ) -> bool: + robot_ids = {self._robot_ids_by_name[group.robot_name] for group in selection.groups} + return all(self.is_collision_free(ctx, robot_id) for robot_id in robot_ids) + + def _overlay_native_group_q_on_robot_q( + self, + group: PlanningGroup, + robot: _RoboPlanRobotData, + native_joint_names: tuple[str, ...], + robot_q: NDArray[np.float64], + native_q: NDArray[np.float64], + ) -> NDArray[np.float64]: + if len(native_q) != len(native_joint_names): + raise ValueError( + f"Planning group '{group.id}' has {len(native_q)} positions, " + f"expected {len(native_joint_names)}" + ) + result = np.asarray(robot_q, dtype=np.float64).copy() + native_map = self._native_names_by_robot[robot.config.name] + robot_indices = { + native_map.joint(name): index for index, name in enumerate(robot.config.joint_names) + } + for native_name, position in zip(native_joint_names, native_q, strict=True): + try: + result[robot_indices[native_name]] = position + except KeyError as exc: + raise ValueError( + f"Planning group '{group.id}' has joint outside robot state: {native_name}" + ) from exc + return result + + def _scene_q_for_group( + self, group: PlanningGroup, robot_id: WorldRobotID, robot_q: NDArray[np.float64] + ) -> NDArray[np.float64]: + ctx = RoboPlanContext(q_by_robot={robot_id: np.asarray(robot_q, dtype=np.float64)}) + for other_robot_id, q in self._live_context.q_by_robot.items(): + ctx.q_by_robot.setdefault(other_robot_id, q) + return self._full_scene_q(ctx) + + def _native_path_to_public_group_path( + self, + group: PlanningGroup, + native_joint_names: tuple[str, ...], + path_arrays: list[NDArray[np.float64]], + result_joint_names: tuple[str, ...] | None, + ) -> list[JointState]: + names_for_positions = result_joint_names or native_joint_names + if len(set(names_for_positions)) != len(names_for_positions): + raise ValueError("RoboPlan path returned duplicate joint names") + if set(names_for_positions) != set(group.local_joint_names): + raise ValueError( + "RoboPlan path joint names do not match selected planning group: " + f"RoboPlan={list(names_for_positions)}, configured={list(group.local_joint_names)}" + ) + path: list[JointState] = [] + for q in path_arrays: + q_array = np.asarray(q, dtype=np.float64) + if len(q_array) != len(names_for_positions): + raise ValueError( + "RoboPlan path waypoint length does not match returned joint names: " + f"{len(q_array)} != {len(names_for_positions)}" + ) + positions_by_name = dict(zip(names_for_positions, q_array, strict=True)) + path.append( + JointState( + name=list(group.joint_names), + position=[float(positions_by_name[name]) for name in group.local_joint_names], + ) + ) + return path + + def _native_path_to_public_selection_path( + self, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + path_arrays: list[NDArray[np.float64]], + result_joint_names: tuple[str, ...] | None, + ) -> list[JointState]: + names_for_positions = result_joint_names or group_data.native_joint_names + if len(set(names_for_positions)) != len(names_for_positions): + raise ValueError("RoboPlan path returned duplicate joint names") + if set(names_for_positions) != set(group_data.native_joint_names): + raise ValueError( + "RoboPlan path joint names do not match selected planning group: " + f"RoboPlan={list(names_for_positions)}, " + f"configured={list(group_data.native_joint_names)}" + ) + native_by_global = { + global_name: native_name + for native_name, global_name in group_data.native_to_global_joint_name.items() + } + path: list[JointState] = [] + for q in path_arrays: + q_array = np.asarray(q, dtype=np.float64) + if len(q_array) != len(names_for_positions): + raise ValueError( + "RoboPlan path waypoint length does not match returned joint names: " + f"{len(q_array)} != {len(names_for_positions)}" + ) + positions_by_native = dict(zip(names_for_positions, q_array, strict=True)) + path.append( + JointState( + name=list(selection.joint_names), + position=[ + float(positions_by_native[native_by_global[global_name]]) + for global_name in selection.joint_names + ], + ) + ) + return path + + def _reorder_jacobian_columns( + self, + group: PlanningGroup, + robot_joint_names: list[str], + native_joint_names: tuple[str, ...], + jacobian: NDArray[np.float64], + ) -> NDArray[np.float64]: + native_group_order = tuple( + self._native_names_by_robot[group.robot_name].joint(name) + for name in group.local_joint_names + ) + if jacobian.shape[1] == len(native_joint_names): + column_names = native_joint_names + elif jacobian.shape[1] == len(self._full_native_joint_names): + column_names = self._full_native_joint_names + elif jacobian.shape[1] == len(robot_joint_names): + native_map = self._native_names_by_robot[group.robot_name] + column_names = tuple(native_map.joint(name) for name in robot_joint_names) + else: + raise ValueError( + f"Unexpected RoboPlan Jacobian shape: {jacobian.shape}; cannot map columns " + f"for planning group '{group.id}'" + ) + column_indices = [column_names.index(name) for name in native_group_order] + return jacobian[:, column_indices] + + def _native_link_name(self, config: RobotModelConfig, local_link_name: str) -> str: + return self._native_names_by_robot[config.name].link(local_link_name) + + def _require_finalized(self) -> None: + if not self._finalized: + raise RuntimeError("World must be finalized first") + + def _require_scene(self) -> Any: + if self._scene is None: + raise RuntimeError("RoboPlan scene is not initialized; finalize the world first") + return self._scene + + def _to_scene_q( + self, + robot_id: WorldRobotID, + q: NDArray[np.float64], + ctx: RoboPlanContext | None = None, + ) -> NDArray[np.float64]: + """Expand DimOS group positions to RoboPlan's full scene vector when available.""" + robot = self._get_robot(robot_id) + if len(q) != len(robot.config.joint_names): + return np.asarray(q, dtype=np.float64) + return self._full_scene_q( + ctx if ctx is not None else self._live_context, overlay=(robot_id, q) + ) + + def _full_scene_q( + self, + ctx: RoboPlanContext, + overlay: tuple[WorldRobotID, NDArray[np.float64]] | None = None, + ) -> NDArray[np.float64]: + positions_by_native: dict[str, float] = {} + for robot_id, robot in self._robots.items(): + q = ctx.q_by_robot.get(robot_id) + if q is None: + q = np.zeros(len(robot.config.joint_names), dtype=np.float64) + if overlay is not None and overlay[0] == robot_id: + q = overlay[1] + if len(q) != len(robot.config.joint_names): + raise ValueError( + f"Robot state for '{robot_id}' has {len(q)} positions, " + f"expected {len(robot.config.joint_names)}" + ) + native_map = self._native_names_by_robot[robot.config.name] + for joint_name, position in zip(robot.config.joint_names, q, strict=True): + positions_by_native[native_map.joint(joint_name)] = float(position) + return np.asarray( + [positions_by_native.get(name, 0.0) for name in self._full_native_joint_names], + dtype=np.float64, + ) + + def _set_scene_current_q(self, ctx: RoboPlanContext) -> None: + scene = self._require_scene() + setter = getattr(scene, "setJointPositions", None) + if setter is not None: + setter(self._full_scene_q(ctx)) + + def _full_robot_planning_group(self, robot_name: RobotName) -> PlanningGroup | None: + try: + robot_id = self._robot_ids_by_name[robot_name] + except KeyError: + return None + robot = self._get_robot(robot_id) + robot_joint_set = set(robot.config.joint_names) + for group in self._planning_groups.groups_for_robot(robot_name): + if set(group.local_joint_names) == robot_joint_set: + return group + return None + + def _has_collisions( + self, robot_id: WorldRobotID, q: NDArray[np.float64], ctx: RoboPlanContext + ) -> bool: + scene = self._require_scene() + scene_q = self._to_scene_q(robot_id, q, ctx) + return bool(scene.hasCollisions(scene_q)) + + def _call_path_collision_checker( + self, + ctx: RoboPlanContext, + robot_id: WorldRobotID, + q_start: NDArray[np.float64], + q_end: NDArray[np.float64], + step_size: float, + ) -> bool: + scene = self._require_scene() + scene_q_start = self._to_scene_q(robot_id, q_start, ctx) + scene_q_end = self._to_scene_q(robot_id, q_end, ctx) + return bool( + roboplan_core.hasCollisionsAlongPath( + scene, + scene_q_start, + scene_q_end, + step_size, + False, + True, + ) + ) + + def _add_obstacle_to_scene(self, obstacle: Obstacle, obstacle_id: str) -> Any: + scene = self._require_scene() + matrix = pose_to_matrix(obstacle.pose) + if obstacle.obstacle_type == ObstacleType.BOX: + self._require_dimensions(obstacle, 3) + width, height, depth = obstacle.dimensions + return scene.addBoxGeometry(obstacle_id, width, height, depth, matrix) + if obstacle.obstacle_type == ObstacleType.SPHERE: + self._require_dimensions(obstacle, 1) + (radius,) = obstacle.dimensions + return scene.addSphereGeometry(obstacle_id, radius, matrix) + if obstacle.obstacle_type == ObstacleType.CYLINDER: + self._require_dimensions(obstacle, 2) + radius, length = obstacle.dimensions + return scene.addCylinderGeometry(obstacle_id, radius, length, matrix) + if obstacle.obstacle_type == ObstacleType.MESH: + if not obstacle.mesh_path: + raise ValueError("MESH obstacle requires mesh_path") + return scene.addMeshGeometry(obstacle_id, obstacle.mesh_path, matrix) + raise ValueError(f"Unsupported obstacle type: {obstacle.obstacle_type}") + + def _require_dimensions(self, obstacle: Obstacle, n_dims: int) -> None: + if len(obstacle.dimensions) != n_dims: + raise ValueError( + f"{obstacle.obstacle_type.name} obstacle requires {n_dims} dimensions, " + f"got {len(obstacle.dimensions)}" + ) + + def _run_native_rrt( + self, + group_name: str, + native_joint_names: tuple[str, ...], + q_start: NDArray[np.float64], + q_goal: NDArray[np.float64], + timeout: float, + ) -> Any: + scene = self._require_scene() + options = roboplan_rrt.RRTOptions() + options.group_name = group_name + options.max_planning_time = timeout + options.collision_check_use_bisection = True + if hasattr(options, "collision_check_step_size"): + options.collision_check_step_size = 0.02 + planner = roboplan_rrt.RRT(scene, options) + start_config = self._to_native_joint_configuration(native_joint_names, q_start) + goal_config = self._to_native_joint_configuration(native_joint_names, q_goal) + result = planner.plan(start_config, goal_config) + if result is None: + raise ValueError("RoboPlan RRT returned no path") + return result + + def _to_native_joint_configuration( + self, native_joint_names: tuple[str, ...], q: NDArray[np.float64] + ) -> Any: + return roboplan_core.JointConfiguration( + list(native_joint_names), np.asarray(q, dtype=np.float64) + ) + + def _extract_native_path( + self, result: Any + ) -> tuple[tuple[str, ...] | None, list[NDArray[np.float64]]]: + if result is None: + raise ValueError("RoboPlan RRT returned no path") + if isinstance(result, (list, tuple)): + return None, [np.asarray(q, dtype=np.float64) for q in result] + result_joint_names = getattr(result, "joint_names", None) + names = tuple(result_joint_names) if result_joint_names else None + return names, [np.asarray(q, dtype=np.float64) for q in result.positions] diff --git a/dimos/manipulation/test_planning_factory.py b/dimos/manipulation/test_planning_factory.py new file mode 100644 index 0000000000..10ca903d1c --- /dev/null +++ b/dimos/manipulation/test_planning_factory.py @@ -0,0 +1,195 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Focused tests for manipulation planning wiring.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest +from pytest_mock import MockerFixture + +from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.manipulation.planning.factory import ( + create_kinematics, + create_planner, + create_planning_stack, + create_world, + validate_backend_combination, +) +from dimos.manipulation.planning.kinematics.config import JacobianKinematicsConfig +from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 + + +@pytest.fixture +def robot_config() -> RobotModelConfig: + return RobotModelConfig( + name="arm", + model_path=Path("/path/to/robot.urdf"), + base_pose=PoseStamped(position=Vector3(), orientation=Quaternion()), # type: ignore[call-arg] + joint_names=["joint1", "joint2"], + end_effector_link="tcp", + coordinator_task_name="traj_arm", + ) + + +def test_create_world_unknown_backend(): + with pytest.raises( + ValueError, match=r"Unknown backend: fake\. Available: \['drake', 'roboplan'\]" + ): + create_world(backend="fake") + + +def test_factory_selects_expected_implementations(): + assert type(create_planner(name="rrt_connect")).__name__ == "RRTConnectPlanner" + assert type(create_kinematics(name="jacobian")).__name__ == "JacobianIK" + + +def test_default_planner_path_does_not_import_roboplan(monkeypatch): + for module_name in list(sys.modules): + if module_name == "roboplan" or module_name.startswith("roboplan."): + monkeypatch.delitem(sys.modules, module_name, raising=False) + + create_planner(name="rrt_connect") + validate_backend_combination() + + assert "roboplan.core" not in sys.modules + assert "roboplan.rrt" not in sys.modules + + +def test_validate_backend_combination_rejects_invalid_combinations(): + with pytest.raises( + ValueError, match='planner_name="roboplan" requires world_backend="roboplan"' + ): + validate_backend_combination(world_backend="drake", planner_name="roboplan") + + with pytest.raises( + ValueError, match='kinematics_name="drake_optimization" requires world_backend="drake"' + ): + validate_backend_combination(world_backend="roboplan", kinematics_name="drake_optimization") + + +def test_create_planner_uses_roboplan_world_as_native_planner(mocker: MockerFixture): + world = mocker.MagicMock() + world.plan_selected_joint_path = mocker.MagicMock() + + assert create_planner(name="roboplan", world=world, world_backend="roboplan") is world + + +def test_create_planner_rejects_roboplan_without_roboplan_world(mocker: MockerFixture): + with pytest.raises( + ValueError, match='planner_name="roboplan" requires world_backend="roboplan"' + ): + create_planner(name="roboplan", world=mocker.MagicMock(), world_backend="drake") + + +def test_create_planning_stack_wires_selected_components( + mocker: MockerFixture, robot_config: RobotModelConfig +): + world = mocker.MagicMock() + world.add_robot.return_value = "robot-id" + + kinematics = mocker.MagicMock(name="kinematics") + planner = mocker.MagicMock(name="planner") + + mock_world = mocker.patch( + "dimos.manipulation.planning.factory.create_world", return_value=world + ) + mock_kinematics = mocker.patch( + "dimos.manipulation.planning.factory.create_kinematics", + return_value=kinematics, + ) + mock_planner = mocker.patch( + "dimos.manipulation.planning.factory.create_planner", + return_value=planner, + ) + + result = create_planning_stack( + robot_config, + world_backend="drake", + planner_name="rrt_connect", + kinematics_name="jacobian", + ) + + assert result == (world, kinematics, planner, "robot-id") + mock_world.assert_called_once_with(backend="drake", visualization=None) + mock_kinematics.assert_called_once_with(config=JacobianKinematicsConfig()) + mock_planner.assert_called_once_with(name="rrt_connect", world=world, world_backend="drake") + world.add_robot.assert_called_once_with(robot_config) + world.finalize.assert_called_once() + + +def test_start_with_no_robots_skips_planning(mocker: MockerFixture): + module = ManipulationModule(robots=[]) + try: + create_world_mock = mocker.patch("dimos.manipulation.manipulation_module.create_world") + create_planning_specs_mock = mocker.patch( + "dimos.manipulation.manipulation_module.create_planning_specs" + ) + + module._initialize_planning() + + assert module._robots == {} + assert module._world_monitor is None + create_world_mock.assert_not_called() + create_planning_specs_mock.assert_not_called() + finally: + module.stop() + + +def test_start_uses_configured_planner_and_kinematics( + mocker: MockerFixture, robot_config: RobotModelConfig +): + module = ManipulationModule(robots=[robot_config], kinematics=JacobianKinematicsConfig()) + try: + world = mocker.MagicMock(name="world") + world_monitor = mocker.MagicMock() + world_monitor.add_robot.return_value = "robot-id" + planner = mocker.MagicMock(name="planner") + kinematics = mocker.MagicMock(name="kinematics") + planning_specs = mocker.MagicMock( + world_monitor=world_monitor, + planner=planner, + kinematics=kinematics, + ) + create_world_mock = mocker.patch( + "dimos.manipulation.manipulation_module.create_world", return_value=world + ) + create_planning_specs_mock = mocker.patch( + "dimos.manipulation.manipulation_module.create_planning_specs", + return_value=planning_specs, + ) + + module._initialize_planning() + + create_world_mock.assert_called_once_with( + backend="drake", visualization=module.config.visualization + ) + create_planning_specs_mock.assert_called_once_with( + world=world, + world_backend="drake", + planner_name="rrt_connect", + kinematics_name=None, + kinematics=module.config.kinematics, + ) + assert module._planner is planner + assert module._kinematics is kinematics + assert module._robots["arm"][0] == "robot-id" + finally: + module.stop() diff --git a/dimos/manipulation/test_roboplan_world.py b/dimos/manipulation/test_roboplan_world.py new file mode 100644 index 0000000000..8959cd49e3 --- /dev/null +++ b/dimos/manipulation/test_roboplan_world.py @@ -0,0 +1,976 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pure-Python tests for the optional RoboPlan world adapter.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +import sys +from types import ModuleType +from typing import Any, ClassVar +import xml.etree.ElementTree as ET + +import numpy as np +import pytest + +from dimos.manipulation.planning.groups.models import ( + PlanningGroupDefinition, + PlanningGroupSelection, +) +from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.manipulation.planning.spec.enums import ObstacleType, PlanningStatus +from dimos.manipulation.planning.spec.models import Obstacle +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.utils.transform_utils import pose_to_matrix + + +class FakeJointConfiguration: + def __init__( + self, joint_names: list[str] | None = None, positions: np.ndarray | None = None + ) -> None: + self.joint_names = joint_names or [] + self.positions = np.asarray(positions if positions is not None else [], dtype=np.float64) + + +class FakeJointPath: + def __init__(self, joint_names: list[str], positions: list[np.ndarray]) -> None: + self.joint_names = joint_names + self.positions = positions + + +class FakeJointGroupInfo: + def __init__(self, joint_names: list[str]) -> None: + self.joint_names = joint_names + + +class FakeScene: + joint_group_joint_names: ClassVar[list[str] | None] = None + position_limits_lower: ClassVar[list[float]] = [-1.0, -2.0] + position_limits_upper: ClassVar[list[float]] = [1.0, 2.0] + + def __init__(self, *args: Any) -> None: + self.constructor_args = args + self.models: list[tuple[str, str, dict[str, str]]] = [] + self.geometry: dict[str, np.ndarray] = {} + self.current_positions: np.ndarray | None = None + self.joint_groups = self._parse_joint_groups(args[2] if len(args) > 2 else None) + + def _parse_joint_groups(self, srdf_path: Any) -> dict[str, list[str]]: + if srdf_path is None: + return {} + try: + root = ET.parse(str(srdf_path)).getroot() + except (ET.ParseError, FileNotFoundError, TypeError): + return {} + groups: dict[str, list[str]] = {} + for group in root.findall("group"): + group_name = group.get("name") + if group_name: + groups[group_name] = [ + name for joint in group.findall("joint") if (name := joint.get("name")) + ] + return groups + + def addRobotModel(self, path: str, name: str, package_paths: dict[str, str]) -> str: + self.models.append((path, name, package_paths)) + return name + + def hasCollisions(self, q: np.ndarray) -> bool: + return bool(np.any(np.asarray(q) > 0.9)) + + def getPositionLimitVectors( + self, group_name: str = "", collapsed: bool = False + ) -> tuple[np.ndarray, np.ndarray]: + _ = (group_name, collapsed) + return np.asarray(self.position_limits_lower), np.asarray(self.position_limits_upper) + + def getJointGroupInfo(self, name: str) -> FakeJointGroupInfo: + if self.joint_group_joint_names is not None: + return FakeJointGroupInfo(self.joint_group_joint_names) + return FakeJointGroupInfo(self.joint_groups[name]) + + def toFullJointPositions(self, group_name: str, q: np.ndarray) -> np.ndarray: + _ = group_name + return q + + def setJointPositions(self, q: np.ndarray) -> None: + self.current_positions = np.asarray(q, dtype=np.float64) + + def addBoxGeometry( + self, obstacle_id: str, width: float, height: float, depth: float, matrix: np.ndarray + ) -> str: + _ = (width, height, depth) + self.geometry[obstacle_id] = matrix + return obstacle_id + + def updateGeometryPlacement(self, handle: str, matrix: np.ndarray) -> None: + self.geometry[handle] = matrix + + def removeGeometry(self, handle: str) -> None: + del self.geometry[handle] + + def forwardKinematics(self, q: np.ndarray, frame_name: str, base_frame: str = "") -> np.ndarray: + _ = (frame_name, base_frame) + mat = np.eye(4) + mat[0, 3] = float(np.sum(q)) + return mat + + def computeFrameJacobian( + self, q: np.ndarray, frame_name: str, local: bool = True + ) -> np.ndarray: + _ = (q, frame_name, local) + group_size = len(self.joint_group_joint_names or next(iter(self.joint_groups.values()))) + columns = np.arange(1, group_size + 1, dtype=np.float64) + return np.tile(columns, (6, 1)) + + +class FakeRRTOptions: + def __init__(self) -> None: + self.group_name = "" + self.timeout = 0.0 + self.max_time = 0.0 + self.max_planning_time = 0.0 + self.collision_check_use_bisection = True + self.collision_check_step_size = 0.05 + + +class FakeRRT: + last_options: ClassVar[FakeRRTOptions | None] = None + last_start: ClassVar[FakeJointConfiguration | None] = None + last_goal: ClassVar[FakeJointConfiguration | None] = None + + def __init__(self, scene: FakeScene, options: FakeRRTOptions) -> None: + self.scene = scene + self.options = options + FakeRRT.last_options = options + + def plan( + self, q_start: FakeJointConfiguration, q_goal: FakeJointConfiguration + ) -> FakeJointPath: + assert isinstance(q_start, FakeJointConfiguration) + assert isinstance(q_goal, FakeJointConfiguration) + FakeRRT.last_start = q_start + FakeRRT.last_goal = q_goal + midpoint = (np.asarray(q_start.positions) + np.asarray(q_goal.positions)) / 2.0 + return FakeJointPath( + q_start.joint_names, + [np.asarray(q_start.positions), midpoint, np.asarray(q_goal.positions)], + ) + + +def _install_fake_roboplan(monkeypatch: pytest.MonkeyPatch) -> None: + roboplan_pkg = ModuleType("roboplan") + roboplan_pkg.__path__ = [] # type: ignore[attr-defined] + core = ModuleType("roboplan.core") + core.Scene = FakeScene # type: ignore[attr-defined] + core.JointConfiguration = FakeJointConfiguration # type: ignore[attr-defined] + + def has_collisions_along_path( + scene: FakeScene, + q_start: np.ndarray, + q_end: np.ndarray, + max_step_size: float, + bisection: bool = False, + check_endpoints: bool = True, + ) -> bool: + _ = (scene, max_step_size, bisection, check_endpoints) + for t in np.linspace(0.0, 1.0, 5): + if scene.hasCollisions(q_start + t * (q_end - q_start)): + return True + return False + + core.hasCollisionsAlongPath = has_collisions_along_path # type: ignore[attr-defined] + + rrt = ModuleType("roboplan.rrt") + rrt.RRTOptions = FakeRRTOptions # type: ignore[attr-defined] + rrt.RRT = FakeRRT # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "roboplan", roboplan_pkg) + monkeypatch.setitem(sys.modules, "roboplan.core", core) + monkeypatch.setitem(sys.modules, "roboplan.rrt", rrt) + + +@pytest.fixture +def fake_roboplan(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_roboplan(monkeypatch) + + +@pytest.fixture +def robot_config(tmp_path: Path) -> RobotModelConfig: + model_path = tmp_path / "robot.urdf" + model_path.write_text( + """ + + + + + + + + + + + + + + + + + + """ + ) + return RobotModelConfig( + name="arm", + model_path=model_path, + base_pose=PoseStamped(position=Vector3(), orientation=Quaternion()), # type: ignore[call-arg] + joint_names=["joint1", "joint2"], + end_effector_link="tcp", + joint_limits_lower=[-1.0, -2.0], + joint_limits_upper=[1.0, 2.0], + ) + + +def _make_world(fake_roboplan: None, robot_config: RobotModelConfig) -> tuple[Any, str]: + module = _import_roboplan_world(fake_roboplan) + + world = module.RoboPlanWorld() + robot_id = world.add_robot(robot_config) + return world, robot_id + + +def _default_selection(world: Any, robot_config: RobotModelConfig) -> PlanningGroupSelection: + return world._planning_groups.select([f"{robot_config.name}/manipulator"]) + + +def _import_roboplan_world(fake_roboplan: None) -> ModuleType: + _ = fake_roboplan + module_name = "dimos.manipulation.planning.world.roboplan_world" + if module_name in sys.modules: + return importlib.reload(sys.modules[module_name]) + return importlib.import_module(module_name) + + +def test_roboplan_bindings_are_imported_at_module_load(fake_roboplan: None) -> None: + module = _import_roboplan_world(fake_roboplan) + + assert module.roboplan_core.Scene is FakeScene + assert module.roboplan_rrt.RRT is FakeRRT + + +def test_robot_registration_finalization_and_joint_limits( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + + assert world.get_robot_ids() == [robot_id] + assert world.get_robot_config(robot_id) is robot_config + assert world._scene is None + assert not world.is_finalized + + lower, upper = world.get_joint_limits(robot_id) + np.testing.assert_allclose(lower, [-1.0, -2.0]) + np.testing.assert_allclose(upper, [1.0, 2.0]) + + world.finalize() + assert world._scene.constructor_args[0] == "arm" + assert Path(world._scene.constructor_args[1]).suffix == ".urdf" + assert Path(world._scene.constructor_args[2]).suffix == ".srdf" + srdf_text = Path(world._scene.constructor_args[2]).read_text() + assert '' in srdf_text + assert 'disable_collisions link1="base" link2="link1"' in srdf_text + assert world.is_finalized + + +def test_scene_joint_limits_are_reordered_to_configured_joint_order( + fake_roboplan: None, robot_config: RobotModelConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + config = robot_config.model_copy( + update={"joint_limits_lower": None, "joint_limits_upper": None} + ) + monkeypatch.setattr(FakeScene, "joint_group_joint_names", ["joint2", "joint1"]) + monkeypatch.setattr(FakeScene, "position_limits_lower", [-2.0, -1.0]) + monkeypatch.setattr(FakeScene, "position_limits_upper", [2.0, 1.0]) + + world, robot_id = _make_world(fake_roboplan, config) + with pytest.raises(RuntimeError, match="joint limits are unavailable until world is finalized"): + world.get_joint_limits(robot_id) + + world.finalize() + + lower, upper = world.get_joint_limits(robot_id) + np.testing.assert_allclose(lower, [-1.0, -2.0]) + np.testing.assert_allclose(upper, [1.0, 2.0]) + + +def test_scene_joint_limits_validate_joint_names( + fake_roboplan: None, robot_config: RobotModelConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + config = robot_config.model_copy( + update={"joint_limits_lower": None, "joint_limits_upper": None} + ) + monkeypatch.setattr(FakeScene, "joint_group_joint_names", ["joint2", "extra_joint"]) + + world, _robot_id = _make_world(fake_roboplan, config) + with pytest.raises(ValueError, match="planning group joint names do not match"): + world.finalize() + + +def test_multiple_robots_can_register_before_composite_finalization( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + second_model = Path(robot_config.model_path).with_name("robot2.urdf") + second_model.write_text(Path(robot_config.model_path).read_text()) + second_config = robot_config.model_copy( + update={"name": "right_arm", "model_path": second_model} + ) + + world = RoboPlanWorld() + first_id = world.add_robot(robot_config) + second_id = world.add_robot(second_config) + + assert world.get_robot_ids() == [first_id, second_id] + assert world._scene is None + assert not world.is_finalized + world.finalize() + assert world.is_finalized + assert world._scene.constructor_args[0] == "dimos_composite" + urdf_text = Path(world._scene.constructor_args[1]).read_text() + srdf_text = Path(world._scene.constructor_args[2]).read_text() + assert "arm__joint1" in urdf_text + assert "right_arm__joint1" in urdf_text + assert 'material name="arm__Black"' in urdf_text + assert 'material name="right_arm__Black"' in urdf_text + assert 'material name="Black"' not in urdf_text + assert "_dimos_composite__arm_manipulator__right_arm_manipulator" in srdf_text + + +def test_composite_urdf_strips_model_world_joint_before_base_pose_attachment( + fake_roboplan: None, tmp_path: Path +) -> None: + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + model_path = tmp_path / "world_joint_robot.urdf" + model_path.write_text( + """ + + + + + + + + + + + + + + + """ + ) + left_config = RobotModelConfig( + name="arm", + model_path=model_path, + base_pose=PoseStamped(position=Vector3(x=0.1), orientation=Quaternion()), # type: ignore[call-arg] + strip_model_world_joint=True, + joint_names=["joint1"], + base_link="base", + end_effector_link="link1", + joint_limits_lower=[-1.0], + joint_limits_upper=[1.0], + ) + right_model_path = tmp_path / "right_world_joint_robot.urdf" + right_model_path.write_text(model_path.read_text()) + right_config = left_config.model_copy( + update={ + "name": "right_arm", + "model_path": right_model_path, + "base_pose": PoseStamped(position=Vector3(x=-0.1), orientation=Quaternion()), # type: ignore[call-arg] + } + ) + + world = RoboPlanWorld() + world.add_robot(left_config) + world.add_robot(right_config) + world.finalize() + + urdf_text = Path(world._scene.constructor_args[1]).read_text() + assert "arm__world_to_base" not in urdf_text + assert "right_arm__world_to_base" not in urdf_text + assert 'parent link="dimos_world"' in urdf_text + assert 'child link="arm__base"' in urdf_text + assert 'child link="right_arm__base"' in urdf_text + + +def test_full_scene_q_includes_non_config_movable_joints( + fake_roboplan: None, tmp_path: Path +) -> None: + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + model_path = tmp_path / "robot_with_gripper.urdf" + model_path.write_text( + """ + + + + + + + + + + + + + + + + + + + + + + + """ + ) + left_config = RobotModelConfig( + name="arm", + model_path=model_path, + base_pose=PoseStamped(position=Vector3(), orientation=Quaternion()), # type: ignore[call-arg] + joint_names=["joint1"], + base_link="base", + end_effector_link="link1", + joint_limits_lower=[-1.0], + joint_limits_upper=[1.0], + ) + right_model_path = tmp_path / "right_robot_with_gripper.urdf" + right_model_path.write_text(model_path.read_text()) + right_config = left_config.model_copy( + update={"name": "right_arm", "model_path": right_model_path} + ) + + world = RoboPlanWorld() + left_id = world.add_robot(left_config) + right_id = world.add_robot(right_config) + world.finalize() + ctx = world.get_live_context() + world.set_joint_state(ctx, left_id, JointState(name=[], position=[0.1])) + world.set_joint_state(ctx, right_id, JointState(name=[], position=[0.2])) + + assert world._full_native_joint_names == ( + "arm__joint1", + "arm__gripper_joint", + "right_arm__joint1", + "right_arm__gripper_joint", + ) + np.testing.assert_allclose(world._full_scene_q(ctx), [0.1, 0.0, 0.2, 0.0]) + + +def test_composite_native_planner_sets_scene_state_and_returns_caller_order( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + second_model = Path(robot_config.model_path).with_name("robot2.urdf") + second_model.write_text(Path(robot_config.model_path).read_text()) + right_config = robot_config.model_copy(update={"name": "right_arm", "model_path": second_model}) + world = RoboPlanWorld() + left_id = world.add_robot(robot_config) + right_id = world.add_robot(right_config) + world.finalize() + ctx = world.get_live_context() + world.set_joint_state(ctx, left_id, JointState(name=[], position=[0.1, 0.2])) + world.set_joint_state(ctx, right_id, JointState(name=[], position=[0.3, 0.4])) + selection = world._planning_groups.select(["right_arm/manipulator", "arm/manipulator"]) + + result = world.plan_selected_joint_path( + world, + selection, + JointState( + name=["right_arm/joint1", "right_arm/joint2", "arm/joint1", "arm/joint2"], + position=[0.3, 0.4, 0.1, 0.2], + ), + JointState( + name=["right_arm/joint1", "right_arm/joint2", "arm/joint1", "arm/joint2"], + position=[0.5, 0.6, 0.7, 0.8], + ), + timeout=1.0, + ) + + assert result.status == PlanningStatus.SUCCESS + assert FakeRRT.last_options is not None + assert ( + FakeRRT.last_options.group_name + == "_dimos_composite__arm_manipulator__right_arm_manipulator" + ) + assert FakeRRT.last_start is not None + assert FakeRRT.last_start.joint_names == [ + "arm__joint1", + "arm__joint2", + "right_arm__joint1", + "right_arm__joint2", + ] + np.testing.assert_allclose(FakeRRT.last_start.positions, [0.1, 0.2, 0.3, 0.4]) + np.testing.assert_allclose( + world._scene.current_positions, + [0.1, 0.2, 0.3, 0.4], + ) + assert result.path[0].name == [ + "right_arm/joint1", + "right_arm/joint2", + "arm/joint1", + "arm/joint2", + ] + assert result.path[-1].position == [0.5, 0.6, 0.7, 0.8] + + +def test_composite_collision_checks_use_scratch_context_for_all_robots( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + second_model = Path(robot_config.model_path).with_name("robot2.urdf") + second_model.write_text(Path(robot_config.model_path).read_text()) + right_config = robot_config.model_copy(update={"name": "right_arm", "model_path": second_model}) + world = RoboPlanWorld() + left_id = world.add_robot(robot_config) + right_id = world.add_robot(right_config) + world.finalize() + + def collides_when_both_robots_move(q: np.ndarray) -> bool: + return bool(q[0] > 0.7 and q[2] > 0.7) + + world._scene.hasCollisions = collides_when_both_robots_move + with world.scratch_context() as ctx: + world.set_joint_state(ctx, left_id, JointState(name=[], position=[0.8, 0.0])) + world.set_joint_state(ctx, right_id, JointState(name=[], position=[0.8, 0.0])) + + assert not world.is_collision_free(ctx, left_id) + assert not world.is_collision_free(ctx, right_id) + + +def test_native_planner_rejects_returned_path_that_collides( + fake_roboplan: None, + robot_config: RobotModelConfig, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class CollidingPathRRT(FakeRRT): + def plan( + self, q_start: FakeJointConfiguration, q_goal: FakeJointConfiguration + ) -> FakeJointPath: + FakeRRT.last_start = q_start + FakeRRT.last_goal = q_goal + return FakeJointPath( + q_start.joint_names, + [ + np.asarray(q_start.positions), + np.asarray([0.95, 0.0]), + np.asarray(q_goal.positions), + ], + ) + + monkeypatch.setattr(sys.modules["roboplan.rrt"], "RRT", CollidingPathRRT) + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_selected_joint_path( + world, + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + JointState(name=["arm/joint1", "arm/joint2"], position=[0.2, 0.2]), + timeout=1.0, + ) + + assert result.status == PlanningStatus.NO_SOLUTION + assert "path is in collision" in result.message + + +def test_selected_start_mismatch_returns_invalid_start( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_selected_joint_path( + world, + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.0]), + JointState(name=["arm/joint1", "arm/joint2"], position=[0.2, 0.0]), + timeout=1.0, + ) + + assert result.status == PlanningStatus.INVALID_START + assert "does not match" in result.message + + +def test_composite_group_generation_cap_is_enforced( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + second_model = Path(robot_config.model_path).with_name("robot2.urdf") + second_model.write_text(Path(robot_config.model_path).read_text()) + right_config = robot_config.model_copy(update={"name": "right_arm", "model_path": second_model}) + world = RoboPlanWorld(max_generated_composite_groups=0) + world.add_robot(robot_config) + world.add_robot(right_config) + + with pytest.raises(ValueError, match="max_generated_composite_groups"): + world.finalize() + + +def test_context_cloning_and_joint_state_round_trip( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + live_state = JointState(name=["joint1", "joint2"], position=[0.1, 0.2]) + world.sync_from_joint_state(robot_id, live_state) + + with world.scratch_context() as scratch: + scratch_state = world.get_joint_state(scratch, robot_id) + assert scratch_state.name == ["joint1", "joint2"] + assert scratch_state.position == [0.1, 0.2] + world.set_joint_state( + scratch, robot_id, JointState(name=["joint1", "joint2"], position=[0.3, 0.4]) + ) + + live_round_trip = world.get_joint_state(world.get_live_context(), robot_id) + assert live_round_trip.position == [0.1, 0.2] + + +def test_global_joint_names_are_applied_to_input_states( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + world.sync_from_joint_state( + robot_id, JointState(name=["arm/joint1", "arm/joint2"], position=[0.2, 0.3]) + ) + + live_round_trip = world.get_joint_state(world.get_live_context(), robot_id) + assert live_round_trip.position == [0.2, 0.3] + + +def test_obstacle_mutation_updates_scene_and_stored_pose( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _ = _make_world(fake_roboplan, robot_config) + world.finalize() + + obstacle = Obstacle( + name="box", + obstacle_type=ObstacleType.BOX, + pose=PoseStamped(position=Vector3(), orientation=Quaternion()), # type: ignore[call-arg] + dimensions=(0.1, 0.2, 0.3), + ) + assert world.add_obstacle(obstacle) == "box" + assert "box" in world._scene.geometry + updated_pose = PoseStamped(position=Vector3(1, 0, 0), orientation=Quaternion()) # type: ignore[call-arg] + assert world.update_obstacle_pose( + "box", + updated_pose, + ) + assert world.get_obstacles()[0].pose is updated_pose + np.testing.assert_allclose(world._scene.geometry["box"], pose_to_matrix(updated_pose)) + assert world.remove_obstacle("box") + assert world.get_obstacles() == [] + + +def test_collision_config_and_edge_checks( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + safe = JointState(name=["joint1", "joint2"], position=[0.1, 0.2]) + colliding = JointState(name=["joint1", "joint2"], position=[0.95, 0.2]) + + assert world.check_config_collision_free(robot_id, safe) + assert not world.check_config_collision_free(robot_id, colliding) + assert not world.check_edge_collision_free(robot_id, safe, colliding, step_size=0.05) + + +def test_collision_check_uses_scene_queries( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + safe = JointState(name=["joint1", "joint2"], position=[0.1, 0.2]) + colliding = JointState(name=["joint1", "joint2"], position=[0.95, 0.2]) + + assert world.check_config_collision_free(robot_id, safe) + assert not world.check_config_collision_free(robot_id, colliding) + + +def test_generic_rrt_planner_uses_roboplan_world_collision_checks( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + from dimos.manipulation.planning.planners.rrt_planner import RRTConnectPlanner + + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + planner = RRTConnectPlanner(step_size=0.5, connect_step_size=0.5, goal_tolerance=10.0) + + start = JointState(name=["joint1", "joint2"], position=[0.0, 0.0]) + goal = JointState(name=["joint1", "joint2"], position=[0.2, 0.1]) + result = planner.plan_joint_path(world, robot_id, start, goal, timeout=1.0, max_iterations=3) + + assert result.status == PlanningStatus.SUCCESS + assert len(result.path) >= 2 + + +def test_group_fk_jacobian_and_explicit_min_distance_unsupported( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + ctx = world.get_live_context() + world.set_joint_state( + ctx, robot_id, JointState(name=["joint1", "joint2"], position=[0.25, 0.5]) + ) + + group_id = f"{robot_config.name}/manipulator" + pose = world.get_group_ee_pose(ctx, group_id) + assert pose.position.x == pytest.approx(0.75) + assert world.get_group_jacobian(ctx, group_id).shape == (6, 2) + assert not hasattr(world, "get_ee_pose") + assert not hasattr(world, "get_jacobian") + with pytest.raises(NotImplementedError, match="get_min_distance"): + world.get_min_distance(ctx, robot_id) + + +def test_selected_native_planner_converts_path( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + selection = _default_selection(world, robot_config) + start = JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]) + goal = JointState(name=["arm/joint1", "arm/joint2"], position=[0.4, 0.2]) + result = world.plan_selected_joint_path(world, selection, start, goal, timeout=1.0) + + assert result.status == PlanningStatus.SUCCESS + assert [state.position for state in result.path] == [[0.0, 0.0], [0.2, 0.1], [0.4, 0.2]] + assert [state.name for state in result.path] == [["arm/joint1", "arm/joint2"]] * 3 + assert FakeRRT.last_options is not None + assert FakeRRT.last_options.group_name == "manipulator" + assert FakeRRT.last_options.collision_check_use_bisection is True + assert FakeRRT.last_options.collision_check_step_size == pytest.approx(0.02) + + +def test_selected_native_planner_handles_unnamed_group_states( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + selection = _default_selection(world, robot_config) + start = JointState(name=[], position=[0.0, 0.0]) + goal = JointState(name=["arm/joint1", "arm/joint2"], position=[0.4, 0.2]) + result = world.plan_selected_joint_path(world, selection, start, goal, timeout=1.0) + + assert result.status == PlanningStatus.SUCCESS + assert [state.name for state in result.path] == [["arm/joint1", "arm/joint2"]] * 3 + + +def test_native_planner_rejects_empty_path( + fake_roboplan: None, robot_config: RobotModelConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + class EmptyPathRRT(FakeRRT): + def plan( + self, q_start: FakeJointConfiguration, q_goal: FakeJointConfiguration + ) -> FakeJointPath: + _ = (q_start, q_goal) + return FakeJointPath(["joint1", "joint2"], []) + + monkeypatch.setattr(sys.modules["roboplan.rrt"], "RRT", EmptyPathRRT) + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + selection = _default_selection(world, robot_config) + start = JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]) + goal = JointState(name=["arm/joint1", "arm/joint2"], position=[0.4, 0.2]) + result = world.plan_selected_joint_path(world, selection, start, goal, timeout=1.0) + + assert result.status == PlanningStatus.NO_SOLUTION + assert result.path == [] + assert "empty path" in result.message + + +def test_selected_native_planner_reorders_native_group_order( + fake_roboplan: None, robot_config: RobotModelConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(FakeScene, "joint_group_joint_names", ["joint2", "joint1"]) + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + ctx = world.get_live_context() + world.set_joint_state( + ctx, robot_id, JointState(name=["joint1", "joint2"], position=[0.25, 0.5]) + ) + + group_id = f"{robot_config.name}/manipulator" + jacobian = world.get_group_jacobian(ctx, group_id) + np.testing.assert_allclose(jacobian[0], [2.0, 1.0]) + + selection = _default_selection(world, robot_config) + start = JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.2]) + goal = JointState(name=["arm/joint1", "arm/joint2"], position=[0.3, 0.4]) + world.set_joint_state( + ctx, robot_id, JointState(name=["joint1", "joint2"], position=start.position) + ) + result = world.plan_selected_joint_path(world, selection, start, goal, timeout=1.0) + + assert result.status == PlanningStatus.SUCCESS + assert FakeRRT.last_start is not None + np.testing.assert_allclose(FakeRRT.last_start.positions, [0.2, 0.1]) + assert FakeRRT.last_start.joint_names == ["joint2", "joint1"] + np.testing.assert_allclose( + [state.position for state in result.path], [[0.1, 0.2], [0.2, 0.3], [0.3, 0.4]] + ) + assert [state.name for state in result.path] == [["arm/joint1", "arm/joint2"]] * 3 + + +def test_selected_native_planner_rejects_non_matching_selection( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + group = world._planning_groups.get("arm/manipulator") + selection = PlanningGroupSelection( + groups=(group,), + group_ids=(group.id,), + joint_names=(group.joint_names[0],), + robot_names=(group.robot_name,), + ) + + result = world.plan_selected_joint_path( + world, + selection, + JointState(name=["arm/joint1"], position=[0.0]), + JointState(name=["arm/joint1"], position=[0.1]), + timeout=1.0, + ) + + assert result.status == PlanningStatus.UNSUPPORTED + assert "exactly match" in result.message + + +def test_provided_srdf_path_is_passed_directly( + fake_roboplan: None, robot_config: RobotModelConfig, tmp_path: Path +) -> None: + srdf_path = tmp_path / "provided.srdf" + srdf_path.write_text( + '' + '' + "" + ) + config = robot_config.model_copy(update={"srdf_path": srdf_path}) + + world, _robot_id = _make_world(fake_roboplan, config) + world.finalize() + + assert world._scene.constructor_args[2] == str(srdf_path) + + +def test_no_srdf_multi_group_configuration_generates_groups( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + config = robot_config.model_copy( + update={ + "planning_groups": [ + PlanningGroupDefinition( + name="arm", + joint_names=("joint1",), + base_link="base", + tip_link="tcp", + ), + PlanningGroupDefinition( + name="wrist", + joint_names=("joint2",), + base_link="link1", + tip_link="tcp", + ), + ] + } + ) + + world = RoboPlanWorld() + world.add_robot(config) + world.finalize() + + srdf_text = Path(world._scene.constructor_args[2]).read_text() + assert '' in srdf_text + assert '' in srdf_text + + +def test_collision_exclusion_pairs_are_written_to_generated_srdf( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + robot_config.collision_exclusion_pairs = [("a", "b")] + world, _ = _make_world(fake_roboplan, robot_config) + world.finalize() + + srdf_path = Path(world._scene.constructor_args[2]) + assert 'disable_collisions link1="a" link2="b"' in srdf_path.read_text() + + +def test_generated_srdf_uses_scoped_temp_directory( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _ = _make_world(fake_roboplan, robot_config) + world.finalize() + + srdf_path = Path(world._scene.constructor_args[2]) + assert srdf_path.parent.name.startswith("dimos_roboplan_srdf_") + assert srdf_path.exists() + assert world._srdf_tempdirs + + +@pytest.mark.parametrize( + ("field_name", "field_value"), + [ + ( + "base_pose", + PoseStamped(position=Vector3(1, 0, 0), orientation=Quaternion()), + ), # type: ignore[call-arg] + ], +) +def test_supported_base_pose_registers_before_planning( + fake_roboplan: None, + robot_config: RobotModelConfig, + field_name: str, + field_value: Any, +) -> None: + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + setattr(robot_config, field_name, field_value) + world = RoboPlanWorld() + world.add_robot(robot_config) diff --git a/dimos/robot/manipulators/xarm/blueprints/basic.py b/dimos/robot/manipulators/xarm/blueprints/basic.py index ea38771f3f..1edfc2355e 100644 --- a/dimos/robot/manipulators/xarm/blueprints/basic.py +++ b/dimos/robot/manipulators/xarm/blueprints/basic.py @@ -34,6 +34,9 @@ xarm7_hardware, ) +_DUAL_XARM6_LEFT_Y_OFFSET = 0.3 +_DUAL_XARM6_RIGHT_Y_OFFSET = -0.3 + xarm6_planner_only = ManipulationModule.blueprint( robots=[make_xarm6_model_config(name="arm")], planning_timeout=10.0, @@ -42,8 +45,8 @@ dual_xarm6_planner = ManipulationModule.blueprint( robots=[ - make_xarm6_model_config(name="left_arm", y_offset=0.5), - make_xarm6_model_config(name="right_arm", y_offset=-0.5), + make_xarm6_model_config(name="left_arm", y_offset=_DUAL_XARM6_LEFT_Y_OFFSET), + make_xarm6_model_config(name="right_arm", y_offset=_DUAL_XARM6_RIGHT_Y_OFFSET), ], planning_timeout=10.0, visualization={"backend": "meshcat"}, @@ -65,8 +68,8 @@ dual_xarm6_planner_coordinator = autoconnect( planner( robots=[ - make_xarm6_model_config(name="left_arm", y_offset=0.5), - make_xarm6_model_config(name="right_arm", y_offset=-0.5), + make_xarm6_model_config(name="left_arm", y_offset=_DUAL_XARM6_LEFT_Y_OFFSET), + make_xarm6_model_config(name="right_arm", y_offset=_DUAL_XARM6_RIGHT_Y_OFFSET), ], planning_timeout=10.0, visualization={"backend": "viser", "allow_plan_execute": True}, diff --git a/dimos/robot/test_all_blueprints.py b/dimos/robot/test_all_blueprints.py index 0655687579..3497276e14 100644 --- a/dimos/robot/test_all_blueprints.py +++ b/dimos/robot/test_all_blueprints.py @@ -49,6 +49,7 @@ "coordinator-velocity-xarm6", "coordinator-xarm6", "coordinator-xarm7", + "dual-xarm6-planner", "dual-xarm6-planner-coordinator", "teleop-hosted-go2", "teleop-hosted-xarm7", diff --git a/docs/adr/0001-generated-composite-roboplan-models.md b/docs/adr/0001-generated-composite-roboplan-models.md new file mode 100644 index 0000000000..050d9f4542 --- /dev/null +++ b/docs/adr/0001-generated-composite-roboplan-models.md @@ -0,0 +1,5 @@ +# Use generated Composite RoboPlan models for multi-robot planning + +For multi-robot RoboPlan planning, DimOS will generate one Composite RoboPlan model: a single RoboPlan-facing URDF/SRDF built from the registered robot models. This lets RoboPlan plan coupled Composite planning groups and check inter-robot collisions in one `Scene`, instead of using separate per-robot scenes that cannot represent coordinated collision-aware motion. + +Single-robot RoboPlan may continue to pass a provided SRDF directly. Multi-robot RoboPlan always generates a composite SRDF at world finalization, including deterministic Composite planning groups for all non-overlapping planning-group combinations within a safety cap. Non-selected joints remain fixed by setting RoboPlan `Scene` current joint positions from the Planning world before invoking group RRT. diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index ca130272c8..8c2403ab2f 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -181,6 +181,21 @@ visualization backend. | `xarm-perception-agent` | XArm7 perception + LLM agent | | `xarm-perception-sim` | XArm7 simulation perception stack | +### Dual XArm6 RoboPlan + Viser QA + +Run the coupled dual-arm RoboPlan backend with Viser using: + +```bash +uv run dimos run dual-xarm6-planner-coordinator \ + -o manipulationmodule.world_backend=roboplan \ + -o manipulationmodule.planner_name=roboplan \ + -o manipulationmodule.visualization.backend=viser +``` + +RoboPlan builds one generated Composite RoboPlan model for the registered arms, +keeps DimOS public joint names in `robot/joint` form, and returns planned paths in +the caller's selected planning-group order. + ## Supported Robots | Robot | DOF | Teleop | Planning | Perception | diff --git a/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/.openspec.yaml b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/.openspec.yaml new file mode 100644 index 0000000000..fab62b414b --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-24 diff --git a/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/design.md b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/design.md new file mode 100644 index 0000000000..72796295a0 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/design.md @@ -0,0 +1,104 @@ +## Context + +DimOS manipulation planning uses a Planning world as the authoritative belief state for robot and scene state. The public planning surface is group-oriented: callers select one or more Planning groups and expect returned paths in global joint-name order. + +RoboPlan, however, constructs a `Scene` from a URDF/SRDF pair and selects an existing SRDF group through `RRTOptions.group_name`. Current `RoboPlanWorld` supports only one robot per `Scene`, creates the scene during `add_robot`, and cannot represent inter-robot collisions or coupled bimanual motion. RoboPlan does not expose a dynamic API for adding planning groups after scene construction, so any Composite planning groups must exist in the generated SRDF before the RoboPlan `Scene` is created. + +The accepted architectural direction is captured in `docs/adr/0001-generated-composite-roboplan-models.md`: multi-robot RoboPlan uses one generated Composite RoboPlan model, not one RoboPlan scene per robot. + +## Goals / Non-Goals + +**Goals:** + +- Support coupled RoboPlan-native planning across multiple selected Planning groups. +- Build one Composite RoboPlan model from registered robot models, applying each `RobotModelConfig.base_pose` exactly once. +- Preserve DimOS public names and selection order at API boundaries while using RoboPlan-native prefixed names internally. +- Generate deterministic Composite planning groups eagerly at world finalization. +- Keep inter-robot collisions enabled by default and preserve per-robot collision exclusions where they can be rewritten safely. +- Keep non-selected joints fixed at the Planning world's current full state during RoboPlan group RRT. + +**Non-Goals:** + +- Runtime mutation of RoboPlan planning groups after scene construction. +- A user-facing configuration language for declaring named Composite planning groups. +- Splitting returned bimanual paths into per-robot plans for the coordinator. +- Baking dynamic obstacles into the generated URDF/SRDF. +- Supporting arbitrary model formats beyond the robot model inputs already accepted by `RobotModelConfig`. + +## Decisions + +### Generate one Composite RoboPlan model for multi-robot worlds + +For two or more robots, `RoboPlanWorld` will defer RoboPlan `Scene` construction until finalization and generate one URDF/SRDF pair containing all registered robots under a synthetic world root. Each robot is attached by a fixed joint using `RobotModelConfig.base_pose`. + +**Rationale:** One RoboPlan `Scene` is required for inter-robot collision checks and coupled planning. Separate scenes are simpler but cannot reason about cross-robot collisions or coordinated motion. + +**Alternative considered:** One RoboPlan scene per robot. Rejected for true bimanual planning because it only supports independent per-arm motion. + +### Prefix RoboPlan-native names, preserve DimOS public names + +All robot-local URDF link, joint, and frame names will be rewritten with a RoboPlan-native-safe prefix, such as `left_arm__joint1`. Public DimOS names remain `left_arm/joint1`, and `PlanningResult.path` remains in global selection order. + +**Rationale:** Multiple robots often share local joint and link names. Prefixing prevents collisions in the composite URDF/SRDF while preserving the existing DimOS API contract. + +**Alternative considered:** Use global DimOS names directly in RoboPlan. Rejected because `/` may not be safe across all URDF/SRDF consumers and because RoboPlan-facing names should remain backend-private. + +### Generate Composite planning groups eagerly with a safety cap + +At finalization, RoboPlan will generate one SRDF group for each non-overlapping Planning-group combination of size at least two, plus each individual configured Planning group. Composite group identity uses canonical registry order, not caller selection order. If the number of generated Composite planning groups exceeds `max_generated_composite_groups`, finalization fails clearly. + +**Rationale:** RoboPlan groups are selected by name from the SRDF at planning time and cannot be added dynamically. Eager generation makes supported combinations deterministic and avoids hidden scene rebuilds. + +**Alternative considered:** Lazy generation per request. Rejected because it would require rebuilding the RoboPlan `Scene` and invalidating backend state. + +### Multi-robot SRDF is always generated + +Single-robot RoboPlan may continue to pass a provided `RobotModelConfig.srdf_path` directly to RoboPlan. Multi-robot RoboPlan always generates a composite SRDF. Per-robot SRDFs may be parsed as source material for group and collision-disable extraction, but they are not passed through as final RoboPlan input. + +**Rationale:** A multi-robot SRDF must reference the prefixed names in the generated composite URDF. Passing through per-robot SRDFs would reference stale names and omit Composite planning groups. + +### Set RoboPlan full current state before group RRT + +Before invoking RoboPlan-native RRT, DimOS will assemble the full composite scene joint vector from the Planning world and call RoboPlan's full current joint-state setter. Start and goal are still passed as `JointConfiguration` values for the selected RoboPlan group, and options such as `collision_check_use_bisection` are set explicitly. + +**Rationale:** RoboPlan's group expansion holds non-selected joints at `Scene` current state. That current state must match the Planning world for fixed joints and non-selected robots to be meaningful. + +**Alternative considered:** Trust only the `start` `JointState`. Rejected because the start is a selected projection, not the authoritative full-world state. + +### Reject selected-start disagreement with the Planning world + +`plan_selected_joint_path` will compare the selected `start` against the Planning world's current selected state. If they disagree beyond a tight configurable tolerance, it returns `PlanningStatus.INVALID_START` before invoking RoboPlan. + +**Rationale:** The Planning world is the source of truth. Allowing an independent start state would make non-selected state and selected state internally inconsistent. + +### Return caller-order global paths + +RoboPlan plans in canonical native group order. DimOS will convert each returned waypoint back to a global `JointState` in `PlanningGroupSelection.joint_names` order. + +**Rationale:** This keeps RoboPlan backend details private and preserves the current selected-planning contract. + +## Risks / Trade-offs + +- **Composite URDF/SRDF rewriting is complex** → Keep prefixing/mapping helpers small and covered by focused unit tests for links, joints, frames, limits, groups, and collision pairs. +- **Base placement can be applied twice** → Treat `RobotModelConfig.base_pose` as the only composite placement source and continue honoring `strip_model_world_joint` when removing model-authored world joints. +- **Composite group count can grow combinatorially** → Enforce `max_generated_composite_groups` and fail finalization with a message that lists how many groups would be generated. +- **Per-robot SRDF collision disables may not always rewrite safely** → Preserve explicit `collision_exclusion_pairs`; rewrite SRDF disables only when both referenced links can be mapped unambiguously. +- **RoboPlan Python defaults may diverge from C++ defaults** → Set planner options explicitly in DimOS instead of relying on binding defaults. +- **Generated model bugs can be hard to diagnose** → Persist or expose generated URDF/SRDF paths in errors/logs so users can inspect the exact RoboPlan input. + +## Migration Plan + +1. Introduce composite model data structures and name-mapping helpers without changing single-robot behavior. +2. Move RoboPlan `Scene` construction from `add_robot` to world finalization and keep single-robot pass-through behavior intact. +3. Add generated composite URDF/SRDF construction for multi-robot registrations. +4. Add Composite planning group lookup and selected-planning conversion. +5. Update collision/FK/Jacobian helpers to assemble full composite q vectors from the Planning world. +6. Add dual-arm tests and run existing single-robot RoboPlan tests to guard compatibility. + +Rollback is straightforward before adoption: disable the multi-robot path and retain the existing single-robot RoboPlan backend. After callers depend on coupled multi-robot planning, rollback requires changing those blueprints back to Drake or another supported backend. + +## Open Questions + +- Should future user-facing configuration allow named Composite planning groups instead of generating every non-overlapping combination? +- What is the first supported path for extracting collision disables from arbitrary per-robot SRDF files when references are ambiguous? +- Should `max_generated_composite_groups` be exposed through blueprint config immediately or remain a constructor-only backend option initially? diff --git a/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/proposal.md b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/proposal.md new file mode 100644 index 0000000000..6614ca9fe1 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/proposal.md @@ -0,0 +1,28 @@ +## Why + +RoboPlan currently cannot plan coupled motion for multiple registered robots because `RoboPlanWorld` builds one RoboPlan `Scene` from one robot model and rejects additional robots. Dual-arm manipulation needs one collision-aware planning scene where selected planning groups can move together while the Planning world remains the authoritative belief state. + +## What Changes + +- Add multi-robot RoboPlan support by finalizing registered robot models into one Composite RoboPlan model: a generated RoboPlan-facing URDF and SRDF. +- Generate deterministic Composite planning groups for all non-overlapping planning-group combinations of size two or greater, subject to a configurable safety cap. +- Preserve DimOS public names (`robot/joint`, planning-group IDs) while mapping them to RoboPlan-native prefixed names inside the composite model. +- Support coupled RoboPlan-native planning for multi-group selections by routing each supported selection to one generated Composite planning group. +- Set RoboPlan `Scene` current full joint positions from the Planning world before invoking group RRT so non-selected joints are held at the authoritative belief state. +- Return `PlanningResult.path` as global `JointState` waypoints in the caller's requested `PlanningGroupSelection.joint_names` order. +- Keep single-robot RoboPlan behavior compatible, including direct use of a provided `RobotModelConfig.srdf_path` when only one robot is registered. + +## Capabilities + +### New Capabilities +- `roboplan-composite-multi-robot-planning`: Covers generated Composite RoboPlan models, Composite planning groups, multi-robot RoboPlan naming/order mappings, and coupled selected-group planning semantics. + +### Modified Capabilities +- None. There are no existing repo-level OpenSpec capabilities to modify. + +## Impact + +- Affects `RoboPlanWorld` robot registration, scene construction/finalization, SRDF/URDF preparation, joint-order conversion, collision checks, FK/Jacobian routing, and `plan_selected_joint_path`. +- Affects tests for RoboPlan world behavior, dual-arm planning, generated SRDF/URDF content, and factory wiring. +- Uses existing RoboPlan Python APIs: `Scene`, `JointConfiguration`, group metadata, group limit queries, explicit RRT options, and full-scene current joint state. +- Adds implementation complexity around prefixing, base placement, collision-disable rewriting, and global/local/native name conversion. diff --git a/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/specs/roboplan-composite-multi-robot-planning/spec.md b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/specs/roboplan-composite-multi-robot-planning/spec.md new file mode 100644 index 0000000000..f0d475b328 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/specs/roboplan-composite-multi-robot-planning/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: Multi-robot RoboPlan finalization +`RoboPlanWorld` SHALL support registering multiple robot models before constructing the RoboPlan `Scene`, and SHALL construct one Composite RoboPlan model when two or more robots are registered. + +#### Scenario: Finalize two registered robots +- **WHEN** two robot configs are registered with distinct robot names +- **THEN** RoboPlan world finalization creates one RoboPlan `Scene` from a generated composite URDF and generated composite SRDF + +#### Scenario: Single robot keeps direct SRDF pass-through +- **WHEN** exactly one robot config is registered with `srdf_path` set +- **THEN** RoboPlan world finalization passes that SRDF path directly to RoboPlan instead of forcing composite SRDF generation + +### Requirement: Composite RoboPlan native naming +The system SHALL rewrite RoboPlan-facing link, joint, frame, and planning-group names so every robot-local name is unique in the Composite RoboPlan model, while preserving DimOS public global names at API boundaries. + +#### Scenario: Duplicate local joint names across robots +- **WHEN** two registered robots both contain a local joint named `joint1` +- **THEN** the generated RoboPlan model uses distinct native joint names for each robot and the public names remain `robot_name/joint1` + +#### Scenario: Path returned from native RoboPlan order +- **WHEN** RoboPlan returns a path in native Composite planning-group order +- **THEN** `PlanningResult.path` contains global `JointState` waypoints in the caller's selected joint order + +### Requirement: Composite URDF base placement +The generated composite URDF SHALL attach each registered robot under a synthetic world root using its `RobotModelConfig.base_pose` exactly once. + +#### Scenario: Robot has non-identity base pose +- **WHEN** a robot config has a non-identity `base_pose` +- **THEN** the generated composite URDF contains one fixed transform from the synthetic world root to that robot's prefixed base frame + +#### Scenario: Model-authored world joint stripping is enabled +- **WHEN** `strip_model_world_joint` is enabled for a robot model +- **THEN** the model-authored world/base fixed joint is removed before applying `RobotModelConfig.base_pose` + +### Requirement: Composite SRDF group generation +The generated composite SRDF SHALL include each configured Planning group and every non-overlapping Planning-group combination of size two or greater, ordered by canonical `PlanningGroupRegistry` order. + +#### Scenario: Dual-arm manipulator groups +- **WHEN** the registry contains `left_arm/manipulator` and `right_arm/manipulator` +- **THEN** the composite SRDF contains a deterministic Composite planning group containing both groups' native joints + +#### Scenario: Caller selection uses a different order +- **WHEN** a caller selects the same groups in a different order than registry order +- **THEN** RoboPlan uses the same generated Composite planning group and DimOS remaps start, goal, and path waypoints at the boundary + +### Requirement: Composite group safety cap +`RoboPlanWorld` SHALL enforce a configurable maximum number of generated Composite planning groups and fail finalization clearly when the cap would be exceeded. + +#### Scenario: Composite group count exceeds cap +- **WHEN** registered Planning groups would generate more Composite planning groups than `max_generated_composite_groups` +- **THEN** finalization fails with an error explaining the cap and the number of groups that would be generated + +### Requirement: Collision disable preservation +The generated composite SRDF SHALL preserve configured per-robot collision exclusions using RoboPlan-native prefixed link names and SHALL leave inter-robot collisions enabled unless explicitly configured otherwise. + +#### Scenario: Configured self-collision exclusion +- **WHEN** a robot config contains a collision exclusion pair for two local links +- **THEN** the generated composite SRDF contains the equivalent disable-collisions entry using that robot's prefixed native link names + +#### Scenario: No inter-robot exclusion configured +- **WHEN** two robots are registered without explicit inter-robot collision exclusions +- **THEN** the generated composite SRDF does not disable collisions between their links + +### Requirement: Planning world state drives RoboPlan current state +Before invoking RoboPlan-native group RRT, `RoboPlanWorld` SHALL set the RoboPlan `Scene` full current joint positions from the Planning world's authoritative belief state. + +#### Scenario: Planning selected group while other robot is fixed +- **WHEN** a selected group is planned while another registered robot is not selected +- **THEN** RoboPlan receives full current joint positions containing the non-selected robot's current Planning world state before RRT planning starts + +### Requirement: Start state consistency validation +`plan_selected_joint_path` SHALL reject a selected `start` state that disagrees with the Planning world's current selected state beyond the configured tolerance. + +#### Scenario: Start differs from Planning world +- **WHEN** the selected `start` joint positions differ from the Planning world's selected positions beyond tolerance +- **THEN** `plan_selected_joint_path` returns `PlanningStatus.INVALID_START` without invoking RoboPlan RRT + +#### Scenario: Start matches Planning world +- **WHEN** the selected `start` joint positions match the Planning world's selected positions within tolerance +- **THEN** `plan_selected_joint_path` may invoke RoboPlan RRT using the selected start and goal + +### Requirement: RoboPlan-native selected planning inputs +`plan_selected_joint_path` SHALL call RoboPlan RRT with `JointConfiguration` start and goal values in the selected RoboPlan group's native joint order and SHALL set RoboPlan planner options explicitly. + +#### Scenario: Composite selected planning request +- **WHEN** a selected planning request maps to a generated Composite planning group +- **THEN** RoboPlan RRT receives `RRTOptions.group_name` for that generated group and `JointConfiguration` start and goal values in native group order + +#### Scenario: Explicit planner options +- **WHEN** RoboPlan RRT options are created +- **THEN** DimOS sets behavior-affecting options such as planning timeout and collision checking mode explicitly instead of relying on binding defaults + +### Requirement: Joint limits use group native order +RoboPlan joint-limit extraction SHALL validate native group joint names by name and reorder limits into the DimOS public or robot-local order required by the caller. + +#### Scenario: RoboPlan returns limits in native order +- **WHEN** RoboPlan reports position limits for a group in native joint order +- **THEN** DimOS validates the joint-name set and reorders limits by name before exposing them through DimOS APIs + +### Requirement: Unsupported selections fail without partial planning +`plan_selected_joint_path` SHALL return `PlanningStatus.UNSUPPORTED` for selections that cannot be represented by a single generated RoboPlan group. + +#### Scenario: Overlapping groups are selected +- **WHEN** a selection contains overlapping joints or groups that were not generated as one Composite planning group +- **THEN** `plan_selected_joint_path` returns `PlanningStatus.UNSUPPORTED` and does not invoke RoboPlan RRT + +### Requirement: Dynamic obstacles remain scene state +Dynamic obstacles SHALL remain Planning world and RoboPlan scene state, not baked into the generated composite URDF or SRDF. + +#### Scenario: Obstacle changes after finalization +- **WHEN** obstacle geometry changes after RoboPlan world finalization +- **THEN** the change is represented through RoboPlan scene/world state updates rather than by regenerating the composite URDF/SRDF diff --git a/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/tasks.md b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/tasks.md new file mode 100644 index 0000000000..4216ecd414 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-composite-multi-robot-planning/tasks.md @@ -0,0 +1,71 @@ +## 1. Composite model scaffolding + +- [x] 1.1 Add RoboPlan-world configuration fields for `max_generated_composite_groups` and selected-start tolerance. +- [x] 1.2 Split RoboPlan robot registration from RoboPlan `Scene` construction so multiple robots can be collected before finalization. +- [x] 1.3 Preserve existing single-robot behavior, including direct `RobotModelConfig.srdf_path` pass-through. +- [x] 1.4 Add an explicit world-finalization path that constructs the RoboPlan `Scene` exactly once before planning/query use. +- [x] 1.5 Add clear errors when planning or scene queries are requested before finalization. + +## 2. Native name mapping + +- [x] 2.1 Implement deterministic RoboPlan-native prefixing for robot-local joint, link, frame, and group names. +- [x] 2.2 Store mappings between DimOS robot-local names, DimOS global names, and RoboPlan-native names. +- [x] 2.3 Update selected-joint conversion helpers to map caller-order global/local inputs to native group order. +- [x] 2.4 Update path extraction to map native RoboPlan paths back to global `JointState` waypoints in caller selection order. +- [x] 2.5 Add duplicate-name and missing-name validation with actionable error messages. + +## 3. Composite URDF generation + +- [x] 3.1 Generate a synthetic world root for multi-robot RoboPlan models. +- [x] 3.2 Rewrite each registered robot model into the composite URDF using RoboPlan-native prefixed names. +- [x] 3.3 Attach each robot to the synthetic world root using `RobotModelConfig.base_pose` exactly once. +- [x] 3.4 Preserve `strip_model_world_joint` behavior to avoid double-applying model-authored world/base joints. +- [x] 3.5 Persist generated composite URDF files in the same temporary/resource lifecycle used by existing generated RoboPlan inputs. + +## 4. Composite SRDF generation + +- [x] 4.1 Generate single-robot SRDF groups from each registered `PlanningGroupDefinition` using native joint names. +- [x] 4.2 Generate Composite planning groups for every non-overlapping Planning-group combination of size at least two. +- [x] 4.3 Use canonical `PlanningGroupRegistry` order for Composite planning group identity and native joint order. +- [x] 4.4 Enforce `max_generated_composite_groups` and fail finalization when the cap is exceeded. +- [x] 4.5 Preserve `RobotModelConfig.collision_exclusion_pairs` as native disable-collisions entries. +- [x] 4.6 Rewrite per-robot SRDF disable-collisions entries when referenced links can be mapped unambiguously. +- [x] 4.7 Keep inter-robot collisions enabled unless an explicit future configuration disables them. + +## 5. Planning world state and scene queries + +- [x] 5.1 Assemble full composite RoboPlan q vectors from the Planning world's per-robot belief state. +- [x] 5.2 Set RoboPlan `Scene` current joint positions from the full Planning world q before group RRT. +- [x] 5.3 Validate selected `start` states against the Planning world's selected state using the configured tolerance. +- [x] 5.4 Update collision checks to evaluate the full composite scene while overlaying candidate selected robot/group states as needed. +- [x] 5.5 Update FK and Jacobian queries to use native prefixed frame names and reorder returned Jacobian columns by DimOS group order. +- [x] 5.6 Keep dynamic obstacle state outside generated URDF/SRDF and route obstacle changes through existing scene-state mechanisms. + +## 6. RoboPlan-native planning + +- [x] 6.1 Resolve `PlanningGroupSelection` sets to generated single or Composite RoboPlan group names. +- [x] 6.2 Return `PlanningStatus.UNSUPPORTED` when a selection cannot map to exactly one generated RoboPlan group. +- [x] 6.3 Build RoboPlan `JointConfiguration` start and goal values in native group order. +- [x] 6.4 Set RoboPlan RRT options explicitly, including group name, timeout, and collision-check behavior. +- [x] 6.5 Convert RoboPlan planner output from native order to caller-order global `JointState` waypoints. +- [x] 6.6 Preserve existing error/status mapping for invalid starts, invalid goals, no-solution paths, and backend exceptions. + +## 7. Tests + +- [x] 7.1 Add unit tests for native name prefixing and global/local/native mapping round trips. +- [x] 7.2 Add unit tests for composite URDF generation with two robots and non-identity base poses. +- [x] 7.3 Add unit tests for `strip_model_world_joint` preventing double base placement. +- [x] 7.4 Add unit tests for composite SRDF group generation, canonical ordering, and safety-cap failure. +- [x] 7.5 Add tests that collision exclusions are preserved and inter-robot collisions remain enabled by default. +- [x] 7.6 Add tests that `plan_selected_joint_path` sets full scene current q before RoboPlan RRT. +- [x] 7.7 Add tests that selected-start disagreement returns `PlanningStatus.INVALID_START` without invoking RoboPlan. +- [x] 7.8 Add tests for coupled dual-arm planning path output in caller selection order. +- [x] 7.9 Add regression tests proving existing single-robot RoboPlan behavior still passes. + +## 8. Verification and documentation + +- [x] 8.1 Run RoboPlan world and planning factory tests. +- [x] 8.2 Run dual xArm blueprint tests relevant to RoboPlan/Viser manual QA. +- [x] 8.3 Run ruff on modified RoboPlan, planning, and test files. +- [x] 8.4 Update developer-facing docs or manual QA notes with the supported dual-arm RoboPlan command and expected constraints. +- [x] 8.5 Verify `openspec status --change roboplan-composite-multi-robot-planning` reports all artifacts complete before implementation starts. diff --git a/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/.openspec.yaml b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/.openspec.yaml new file mode 100644 index 0000000000..fab62b414b --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-24 diff --git a/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/design.md b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/design.md new file mode 100644 index 0000000000..e9b324dddd --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/design.md @@ -0,0 +1,117 @@ +## Context + +The manipulation stack has moved toward planning-group-first APIs. `ManipulationModule` plans through `PlannerSpec.plan_selected_joint_path(...)`, and `WorldSpec` exposes `get_group_ee_pose(...)` and `get_group_jacobian(...)`. Drake already implements those group-oriented world queries. + +`RoboPlanWorld` is still mostly robot-scoped. It stores full robot state in `RoboPlanContext.q_by_robot`, generates an SRDF group, converts `JointState` inputs to `config.joint_names`, and its native planning entry point predates the up-to-date selected-group planner API. RoboPlan itself supports SRDF groups through `Scene.getJointGroupInfo(...)`, `Scene.toFullJointPositions(...)`, and `RRTOptions.group_name`, but it does not expose a dedicated persistent planning-group joint-state API. DimOS must continue owning belief state and pass explicit vectors to RoboPlan queries. + +The user-facing interface should stay lean: use the existing public `PlanningGroup` model and `PlanningGroupRegistry`; avoid adding a custom group dataclass unless it encodes backend-only information not already available publicly. + +## Goals / Non-Goals + +**Goals:** + +- Make RoboPlan-native planning implement `plan_selected_joint_path(...)` for a single selected planning group. +- Keep full robot-local state as the canonical world belief in `RoboPlanContext.q_by_robot`. +- Convert between public group order, robot-local order, and RoboPlan native group order by joint name. +- Use existing `PlanningGroup` objects for public group metadata. +- Store only minimal RoboPlan-specific metadata, primarily native joint order per planning group. +- Keep generated SRDF support simple and deterministic. +- Keep the RoboPlan adapter interface minimal by implementing only the up-to-date `PlannerSpec` and `WorldSpec` group-oriented methods. + +**Non-Goals:** + +- Do not add multi-group or multi-robot RoboPlan-native coupled planning in this change. +- Do not build a full SRDF authoring or parsing system in `RoboPlanWorld`. +- Do not replace the public planning-group models or create a parallel custom interface. +- Do not change the high-level `ManipulationModule` planning API. +- Do not add new RoboPlan dependencies. +- Do not preserve RoboPlan legacy wrapper APIs such as `plan_joint_path(...)`, `get_ee_pose(...)`, or `get_jacobian(...)`. +- If protocol definitions still expose deprecated robot-scoped planner/world methods, update the protocol/factory contract so RoboPlan is validated through the group-oriented APIs only. + +## Decisions + +### Decision: Keep belief state in full robot-local order + +`RoboPlanContext.q_by_robot[robot_id]` remains the canonical belief state, ordered by `robot.config.joint_names`. + +Rationale: live joint-state synchronization, compatibility collision checks, and whole-robot wrappers already operate on full robot state. RoboPlan does not provide a persistent per-group state API, so DimOS must own state and provide explicit query vectors. + +Alternative considered: store group state directly in the context. Rejected because group selections can overlap or omit joints, and collision/FK calls still need a full-scene vector. + +### Decision: Use public `PlanningGroup` plus minimal backend metadata + +`RoboPlanWorld` should add a `PlanningGroupRegistry` and a robot-name lookup, then store only backend-specific extras: + +```python +self._planning_groups: PlanningGroupRegistry +self._robot_ids_by_name: dict[RobotName, WorldRobotID] +self._roboplan_native_joint_names: dict[PlanningGroupID, tuple[str, ...]] +``` + +Rationale: `PlanningGroup` already contains group ID, robot name, public/global joint names, local joint names, base link, and tip link. A new `_RoboPlanGroupData` class would duplicate public fields without adding meaning. + +Alternative considered: create a RoboPlan-specific group dataclass. Rejected unless implementation later reveals multiple backend-only fields that are always passed together. + +### Decision: Keep generated SRDF narrow; require SRDF for complex cases + +When `RobotModelConfig.srdf_path` is absent, generated SRDF supports exactly one configured planning group and writes that group using `PlanningGroupDefinition.name`. If more than one group is configured, `RoboPlanWorld.add_robot(...)` fails early with a message instructing the caller to provide `RobotModelConfig.srdf_path`. + +When `RobotModelConfig.srdf_path` is present, pass it directly to `roboplan_core.Scene(...)` and validate each configured DimOS group against `Scene.getJointGroupInfo(group.group_name)`. + +Rationale: RoboPlan already consumes SRDF. Complex group semantics should live in user-provided SRDF instead of expanding DimOS into a partial SRDF generator. + +Alternative considered: generate all configured SRDF groups. Rejected because it duplicates SRDF semantics and risks generating incomplete or misleading SRDF for complex robots. + +### Decision: Centralize conversions by name + +All order changes should go through helpers. Suggested helper responsibilities: + +- convert named or unnamed local `JointState` to full robot-local `q` +- project full robot-local `q` to RoboPlan native group `q` +- convert public selected global `JointState` to RoboPlan native group `q` +- convert RoboPlan native group path waypoints back to public selected global `JointState` +- overlay group-native positions onto a full robot-local `q` only if a current group-oriented query requires non-group joints to be preserved + +Rationale: this refactor is about avoiding hidden order assumptions. Conversion code should be small, explicit, and test-covered. + +Alternative considered: inline conversions in planning/FK methods. Rejected because duplicated order logic is error-prone. + +### Decision: Support one selected group first + +`plan_selected_joint_path(...)` initially supports only selections containing exactly one planning group on one robot where the selected joints exactly match that group. Unsupported selections return `PlanningStatus.UNSUPPORTED`. + +Rationale: RoboPlan RRT accepts one `RRTOptions.group_name`. Supporting coupled multi-group planning requires additional semantics not needed for the immediate integration. + +Alternative considered: silently fall back to full robot planning for multi-group selections. Rejected because it would obscure unsupported behavior and can plan joints the caller did not select. + +### Decision: Implement only current protocol APIs + +RoboPlan should implement the up-to-date protocol surface used by the current codebase: `PlannerSpec.plan_selected_joint_path(...)` plus `WorldSpec.get_group_ee_pose(...)` and `WorldSpec.get_group_jacobian(...)`. Legacy RoboPlan-specific robot-scoped planning/FK/Jacobian wrapper behavior should be removed from the refactor plan rather than maintained. If the protocol file still includes deprecated robot-scoped methods, this change should narrow the RoboPlan planner/world validation path to the group-oriented contract. + +Rationale: a compatibility layer would enlarge the adapter surface and keep order-conversion behavior alive in paths the current stack should no longer call. The goal is to make RoboPlan follow the same minimal group-native interface as the rest of the up-to-date planning stack. + +Alternative considered: keep wrappers for old callers. Rejected because the user explicitly wants the RoboPlan interface to stay bare-minimal and aligned only with current `PlannerSpec`/`WorldSpec`. + +## Risks / Trade-offs + +- Generated SRDF becomes stricter for multi-group configurations → Mitigation: fail early with a clear message and support user-provided `srdf_path`. +- RoboPlan native group order may differ from DimOS public order → Mitigation: store and test `Scene.getJointGroupInfo(...).joint_names`, and reorder by name at every boundary. +- Returned `JointPath` may not expose names in every binding variant → Mitigation: if names are present, validate/reorder by them; otherwise assume the configured native order only after validating waypoint lengths. +- Removing RoboPlan legacy wrappers may break stale callers that still invoke robot-scoped methods directly → Mitigation: update tests/factory usage to the current protocol and let stale callers fail loudly instead of preserving duplicate paths. +- Existing fake RoboPlan tests may not model all binding details → Mitigation: extend fakes only around documented APIs used by the adapter and keep failure modes explicit. + +## Migration Plan + +1. Add registry and minimal native-order metadata during `RoboPlanWorld.add_robot(...)`. +2. Update SRDF selection/generation logic with clear validation. +3. Add conversion helpers and tests for order projections. +4. Implement group FK/Jacobian only through the current `WorldSpec` group methods. +5. Implement `plan_selected_joint_path(...)` as the RoboPlan native planning entry point. +6. Update protocol/factory validation as needed so RoboPlan is checked against the group-oriented current contract, not deprecated robot-scoped methods. +7. Run targeted RoboPlan world/planning factory tests. + +Rollback is straightforward because changes are isolated to the RoboPlan adapter and tests; revert the adapter and factory validation if native group planning causes regressions. + +## Open Questions + +- Should `RoboPlanWorld` accept one generated subset group without SRDF, or should generated SRDF require the group to cover all controllable joints for maximum predictability? diff --git a/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/proposal.md b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/proposal.md new file mode 100644 index 0000000000..9c82cb6dac --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/proposal.md @@ -0,0 +1,42 @@ +## Why + +`RoboPlanWorld` is wired into a manipulation stack that now plans through public planning groups, but its native planner path still assumes robot-local joint order and uses the robot name as the RoboPlan group. This makes RoboPlan planning fragile when public group order, RoboPlan native group order, and full robot state order diverge. + +This change aligns RoboPlan planning, FK, and Jacobian queries with the existing planning-group API while keeping the custom backend interface lean and minimal. + +## What Changes + +- Add RoboPlan support for `PlannerSpec.plan_selected_joint_path(...)` over a single selected planning group. +- Preserve full robot-local context state as the world belief model; project between full robot state, public group order, and RoboPlan native group order by name. +- Use existing public `PlanningGroup` data instead of introducing a new custom group interface; store only RoboPlan-specific native joint order as adapter metadata. +- Keep auto-generated SRDF support intentionally simple: one configured planning group only. Require `RobotModelConfig.srdf_path` for multi-group or more complex RoboPlan SRDF semantics. +- Implement only the up-to-date `WorldSpec` group APIs (`get_group_ee_pose(...)`, `get_group_jacobian(...)`) and `PlannerSpec.plan_selected_joint_path(...)` for RoboPlan. +- Remove RoboPlan-specific legacy planning/world wrapper expectations instead of preserving `plan_joint_path(...)`, `get_ee_pose(...)`, or `get_jacobian(...)` compatibility behavior. +- Return explicit unsupported or validation failures for multi-group, multi-robot, missing SRDF, mismatched group names, or mismatched joint sets instead of silently assuming order compatibility. + +## Capabilities + +### New Capabilities +- `roboplan-planning-group-native-order`: RoboPlan world/planner behavior uses planning-group-native joint ordering while preserving full robot belief state and keeping the RoboPlan interface minimal. + +### Modified Capabilities + +None. + +## Impact + +- Affected code: + - `dimos/manipulation/planning/world/roboplan_world.py` + - `dimos/manipulation/planning/spec/protocols.py` if the current protocol definitions still expose deprecated robot-scoped planner/world methods + - `dimos/manipulation/planning/factory.py` + - `dimos/manipulation/test_roboplan_world.py` + - related planning-group tests if integration behavior needs coverage +- Public planning API impact: + - Enables the existing `plan_selected_joint_path(...)` contract for RoboPlan-native planning. + - Implements only current `PlannerSpec`/`WorldSpec` group-oriented APIs for RoboPlan. +- Dependency impact: + - No new dependencies. + - RoboPlan still consumes URDF/SRDF through existing bindings. +- Behavior impact: + - Simple generated SRDF remains supported for one planning group. + - Complex/multi-group RoboPlan setups must provide `RobotModelConfig.srdf_path`. diff --git a/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/specs/roboplan-planning-group-native-order/spec.md b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/specs/roboplan-planning-group-native-order/spec.md new file mode 100644 index 0000000000..27504464af --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/specs/roboplan-planning-group-native-order/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: RoboPlan registers configured planning groups +`RoboPlanWorld` SHALL register planning groups from `RobotModelConfig.planning_groups` using the existing public planning-group registry model. + +#### Scenario: Planning group metadata is validated against RoboPlan +- **WHEN** a robot is added to `RoboPlanWorld` +- **THEN** the world SHALL query RoboPlan group metadata for each configured planning group +- **AND** the world SHALL validate that the RoboPlan native joint-name set matches the configured planning group's local joint-name set + +#### Scenario: Native joint order is preserved as adapter metadata +- **WHEN** RoboPlan returns a planning group's joint names in a different order than the configured public planning group +- **THEN** the world SHALL preserve the RoboPlan native order separately from the public planning group order +- **AND** the world SHALL use name-based conversion at RoboPlan boundaries + +### Requirement: RoboPlan SRDF handling is explicit and narrow +`RoboPlanWorld` SHALL either use a caller-provided SRDF or generate only a simple SRDF for a single configured planning group. + +#### Scenario: Provided SRDF is used directly +- **WHEN** `RobotModelConfig.srdf_path` is set +- **THEN** `RoboPlanWorld` SHALL pass that SRDF path to RoboPlan scene construction +- **AND** `RoboPlanWorld` SHALL validate configured planning groups against the RoboPlan scene + +#### Scenario: Generated SRDF supports one configured group +- **WHEN** `RobotModelConfig.srdf_path` is not set and exactly one planning group is configured +- **THEN** `RoboPlanWorld` SHALL generate an SRDF containing that planning group's name and joints +- **AND** the generated SRDF SHALL retain configured and adjacent-link collision exclusions + +#### Scenario: Multi-group generated SRDF is rejected +- **WHEN** `RobotModelConfig.srdf_path` is not set and more than one planning group is configured +- **THEN** `RoboPlanWorld` SHALL reject the robot configuration with a clear error instructing the caller to provide `RobotModelConfig.srdf_path` + +### Requirement: RoboPlan world belief remains full robot local state +`RoboPlanContext` SHALL keep robot state in full robot-local joint order while planning-group operations project from that state by name. + +#### Scenario: Live joint state sync stores full robot state +- **WHEN** a named driver `JointState` is synced into `RoboPlanWorld` +- **THEN** the world SHALL store the robot state ordered by `RobotModelConfig.joint_names` + +#### Scenario: Group operations project from full robot state +- **WHEN** a RoboPlan group operation needs a group vector +- **THEN** the world SHALL project the full robot-local vector into RoboPlan native group order by joint name + +### Requirement: RoboPlan native planner supports selected planning groups +`RoboPlanWorld` SHALL implement `PlannerSpec.plan_selected_joint_path(...)` for one selected planning group using RoboPlan native group order. + +#### Scenario: Selected group plan uses RoboPlan native order +- **WHEN** `plan_selected_joint_path(...)` is called with one supported planning group and valid global start and goal joint states +- **THEN** `RoboPlanWorld` SHALL convert start and goal into RoboPlan native group order +- **AND** it SHALL call RoboPlan RRT with `RRTOptions.group_name` set to the selected group name +- **AND** it SHALL construct RoboPlan `JointConfiguration` values with native joint names and native-order positions + +#### Scenario: Selected group plan returns public order +- **WHEN** RoboPlan returns a successful native path +- **THEN** `RoboPlanWorld` SHALL return `PlanningResult.path` as `JointState` waypoints named with the selected group's public global joint names +- **AND** waypoint positions SHALL be ordered by the public selection order + +#### Scenario: Unsupported selections fail explicitly +- **WHEN** `plan_selected_joint_path(...)` is called with multiple planning groups, multiple robots, or a selection that does not exactly match one configured group +- **THEN** `RoboPlanWorld` SHALL return `PlanningStatus.UNSUPPORTED` with an explanatory message + +### Requirement: RoboPlan group FK and Jacobian use selected group semantics +`RoboPlanWorld` SHALL implement group-scoped FK and Jacobian methods using the selected planning group's target frame and native joint order. + +#### Scenario: Group FK uses target frame +- **WHEN** `get_group_ee_pose(ctx, group_id)` is called for a group with a target frame +- **THEN** `RoboPlanWorld` SHALL project context state into the group's native order +- **AND** it SHALL query RoboPlan forward kinematics for the group's target frame + +#### Scenario: Group Jacobian uses target frame and group columns +- **WHEN** `get_group_jacobian(ctx, group_id)` is called for a group with a target frame +- **THEN** `RoboPlanWorld` SHALL return a 6xN Jacobian whose columns correspond to the public group's local joint order + +### Requirement: RoboPlan exposes only current group-oriented planning contracts +`RoboPlanWorld` SHALL implement the current group-oriented `PlannerSpec` and `WorldSpec` surface without adding RoboPlan-specific legacy compatibility wrappers. + +#### Scenario: Factory validates selected-group planner contract +- **WHEN** RoboPlan is selected as a planner backend +- **THEN** planner creation SHALL validate support for `plan_selected_joint_path(...)` +- **AND** it SHALL not rely on RoboPlan-specific `plan_joint_path(...)` compatibility behavior + +#### Scenario: Protocol validation does not force RoboPlan legacy wrappers +- **WHEN** protocol or factory validation is updated for RoboPlan +- **THEN** RoboPlan SHALL be validated through the group-oriented planner/world methods used by the current codebase +- **AND** RoboPlan SHALL not be required to keep robot-scoped compatibility methods solely to satisfy stale validation + +#### Scenario: Group world queries are the RoboPlan FK and Jacobian interface +- **WHEN** callers need RoboPlan FK or Jacobian data +- **THEN** callers SHALL use `get_group_ee_pose(...)` or `get_group_jacobian(...)` +- **AND** the RoboPlan adapter SHALL not add robot-scoped FK/Jacobian compatibility requirements beyond the current `WorldSpec` diff --git a/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/tasks.md b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/tasks.md new file mode 100644 index 0000000000..7482ec9480 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-roboplan-planning-group-native-order/tasks.md @@ -0,0 +1,49 @@ +## 1. RoboPlan Group Registration and SRDF Policy + +- [x] 1.1 Add `PlanningGroupRegistry`, robot-name lookup, and native joint-order metadata fields to `RoboPlanWorld` without introducing a new custom group dataclass. +- [x] 1.2 Register `RobotModelConfig.planning_groups` during `add_robot(...)` and map each group to its owning robot ID. +- [x] 1.3 Update SRDF path selection so `RobotModelConfig.srdf_path` is passed directly when provided. +- [x] 1.4 Update generated SRDF behavior to allow exactly one configured planning group and use that group's name and joints. +- [x] 1.5 Reject no-SRDF multi-group configurations with a clear `RobotModelConfig.srdf_path` error. +- [x] 1.6 Validate each configured group against `scene.getJointGroupInfo(group.group_name)` and store RoboPlan native joint order. + +## 2. Joint-Order Conversion Helpers + +- [x] 2.1 Add helper to resolve a planning group ID to its public `PlanningGroup`, owning robot ID, robot config, and native joint order. +- [x] 2.2 Add helper to project full robot-local `q` into RoboPlan native group order by local joint name. +- [x] 2.3 Add helper to convert public selected-group `JointState` values into RoboPlan native group `q` values. +- [x] 2.4 Add helper to convert RoboPlan native group path waypoints back into public global selected-group `JointState` order. +- [x] 2.5 Add helper to overlay group positions onto full robot-local `q` only if a current group-oriented query requires preserving non-group joints. + +## 3. Group World Queries + +- [x] 3.1 Implement `get_group_ee_pose(ctx, group_id)` using the group's target frame and native order projection. +- [x] 3.2 Implement `get_group_jacobian(ctx, group_id)` and reorder columns into public group local joint order. +- [x] 3.3 Remove RoboPlan legacy FK/Jacobian wrapper expectations from tests and adapter design; RoboPlan should expose only the group-oriented world query methods. + +## 4. Native Selected-Group Planning + +- [x] 4.1 Implement `plan_selected_joint_path(...)` on `RoboPlanWorld` for one selected planning group on one robot. +- [x] 4.2 Validate selected start and goal states exactly match the configured planning group by joint set. +- [x] 4.3 Convert selected start and goal states into `roboplan_core.JointConfiguration(native_joint_names, native_q)`. +- [x] 4.4 Set `RRTOptions.group_name` to the selected group's RoboPlan group name and keep explicit option values such as `collision_check_use_bisection`. +- [x] 4.5 Extract RoboPlan `JointPath` waypoints, validate waypoint lengths and names when available, and return public global group-order waypoints. +- [x] 4.6 Return `PlanningStatus.UNSUPPORTED` with explanatory messages for multi-group, multi-robot, or non-matching selections. +- [x] 4.7 Remove RoboPlan legacy `plan_joint_path(...)` wrapper expectations and make selected-group planning the native RoboPlan planner entry point. + +## 5. Factory and Compatibility Checks + +- [x] 5.1 Update `create_planner("roboplan", world=...)` validation to require the current group-native `plan_selected_joint_path(...)` planner contract. +- [x] 5.2 Update `PlannerSpec`/`WorldSpec` validation assumptions if they still force RoboPlan to keep deprecated robot-scoped planner/FK/Jacobian wrappers. +- [x] 5.3 Ensure error messages distinguish unsupported planning selections from RoboPlan planner failures. +- [x] 5.4 Preserve existing robot-scoped collision APIs and joint-limit behavior in robot-local `config.joint_names` order because those remain part of world state/collision management. + +## 6. Tests and Verification + +- [x] 6.1 Extend RoboPlan fake scene/planner helpers to expose group info, native group order, and selected-group path behavior. +- [x] 6.2 Add tests for provided SRDF passthrough and single-group generated SRDF naming. +- [x] 6.3 Add tests for no-SRDF multi-group rejection. +- [x] 6.4 Add tests for native group order differing from public group order across planning input and output. +- [x] 6.5 Add tests for `plan_selected_joint_path(...)` success and unsupported selection failures. +- [x] 6.6 Add tests for `get_group_ee_pose(...)` and `get_group_jacobian(...)` group behavior without legacy wrapper coverage. +- [x] 6.7 Run `uv run pytest dimos/manipulation/test_roboplan_world.py dimos/manipulation/test_planning_factory.py -q`. diff --git a/openspec/specs/roboplan-composite-multi-robot-planning/spec.md b/openspec/specs/roboplan-composite-multi-robot-planning/spec.md new file mode 100644 index 0000000000..f0d475b328 --- /dev/null +++ b/openspec/specs/roboplan-composite-multi-robot-planning/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: Multi-robot RoboPlan finalization +`RoboPlanWorld` SHALL support registering multiple robot models before constructing the RoboPlan `Scene`, and SHALL construct one Composite RoboPlan model when two or more robots are registered. + +#### Scenario: Finalize two registered robots +- **WHEN** two robot configs are registered with distinct robot names +- **THEN** RoboPlan world finalization creates one RoboPlan `Scene` from a generated composite URDF and generated composite SRDF + +#### Scenario: Single robot keeps direct SRDF pass-through +- **WHEN** exactly one robot config is registered with `srdf_path` set +- **THEN** RoboPlan world finalization passes that SRDF path directly to RoboPlan instead of forcing composite SRDF generation + +### Requirement: Composite RoboPlan native naming +The system SHALL rewrite RoboPlan-facing link, joint, frame, and planning-group names so every robot-local name is unique in the Composite RoboPlan model, while preserving DimOS public global names at API boundaries. + +#### Scenario: Duplicate local joint names across robots +- **WHEN** two registered robots both contain a local joint named `joint1` +- **THEN** the generated RoboPlan model uses distinct native joint names for each robot and the public names remain `robot_name/joint1` + +#### Scenario: Path returned from native RoboPlan order +- **WHEN** RoboPlan returns a path in native Composite planning-group order +- **THEN** `PlanningResult.path` contains global `JointState` waypoints in the caller's selected joint order + +### Requirement: Composite URDF base placement +The generated composite URDF SHALL attach each registered robot under a synthetic world root using its `RobotModelConfig.base_pose` exactly once. + +#### Scenario: Robot has non-identity base pose +- **WHEN** a robot config has a non-identity `base_pose` +- **THEN** the generated composite URDF contains one fixed transform from the synthetic world root to that robot's prefixed base frame + +#### Scenario: Model-authored world joint stripping is enabled +- **WHEN** `strip_model_world_joint` is enabled for a robot model +- **THEN** the model-authored world/base fixed joint is removed before applying `RobotModelConfig.base_pose` + +### Requirement: Composite SRDF group generation +The generated composite SRDF SHALL include each configured Planning group and every non-overlapping Planning-group combination of size two or greater, ordered by canonical `PlanningGroupRegistry` order. + +#### Scenario: Dual-arm manipulator groups +- **WHEN** the registry contains `left_arm/manipulator` and `right_arm/manipulator` +- **THEN** the composite SRDF contains a deterministic Composite planning group containing both groups' native joints + +#### Scenario: Caller selection uses a different order +- **WHEN** a caller selects the same groups in a different order than registry order +- **THEN** RoboPlan uses the same generated Composite planning group and DimOS remaps start, goal, and path waypoints at the boundary + +### Requirement: Composite group safety cap +`RoboPlanWorld` SHALL enforce a configurable maximum number of generated Composite planning groups and fail finalization clearly when the cap would be exceeded. + +#### Scenario: Composite group count exceeds cap +- **WHEN** registered Planning groups would generate more Composite planning groups than `max_generated_composite_groups` +- **THEN** finalization fails with an error explaining the cap and the number of groups that would be generated + +### Requirement: Collision disable preservation +The generated composite SRDF SHALL preserve configured per-robot collision exclusions using RoboPlan-native prefixed link names and SHALL leave inter-robot collisions enabled unless explicitly configured otherwise. + +#### Scenario: Configured self-collision exclusion +- **WHEN** a robot config contains a collision exclusion pair for two local links +- **THEN** the generated composite SRDF contains the equivalent disable-collisions entry using that robot's prefixed native link names + +#### Scenario: No inter-robot exclusion configured +- **WHEN** two robots are registered without explicit inter-robot collision exclusions +- **THEN** the generated composite SRDF does not disable collisions between their links + +### Requirement: Planning world state drives RoboPlan current state +Before invoking RoboPlan-native group RRT, `RoboPlanWorld` SHALL set the RoboPlan `Scene` full current joint positions from the Planning world's authoritative belief state. + +#### Scenario: Planning selected group while other robot is fixed +- **WHEN** a selected group is planned while another registered robot is not selected +- **THEN** RoboPlan receives full current joint positions containing the non-selected robot's current Planning world state before RRT planning starts + +### Requirement: Start state consistency validation +`plan_selected_joint_path` SHALL reject a selected `start` state that disagrees with the Planning world's current selected state beyond the configured tolerance. + +#### Scenario: Start differs from Planning world +- **WHEN** the selected `start` joint positions differ from the Planning world's selected positions beyond tolerance +- **THEN** `plan_selected_joint_path` returns `PlanningStatus.INVALID_START` without invoking RoboPlan RRT + +#### Scenario: Start matches Planning world +- **WHEN** the selected `start` joint positions match the Planning world's selected positions within tolerance +- **THEN** `plan_selected_joint_path` may invoke RoboPlan RRT using the selected start and goal + +### Requirement: RoboPlan-native selected planning inputs +`plan_selected_joint_path` SHALL call RoboPlan RRT with `JointConfiguration` start and goal values in the selected RoboPlan group's native joint order and SHALL set RoboPlan planner options explicitly. + +#### Scenario: Composite selected planning request +- **WHEN** a selected planning request maps to a generated Composite planning group +- **THEN** RoboPlan RRT receives `RRTOptions.group_name` for that generated group and `JointConfiguration` start and goal values in native group order + +#### Scenario: Explicit planner options +- **WHEN** RoboPlan RRT options are created +- **THEN** DimOS sets behavior-affecting options such as planning timeout and collision checking mode explicitly instead of relying on binding defaults + +### Requirement: Joint limits use group native order +RoboPlan joint-limit extraction SHALL validate native group joint names by name and reorder limits into the DimOS public or robot-local order required by the caller. + +#### Scenario: RoboPlan returns limits in native order +- **WHEN** RoboPlan reports position limits for a group in native joint order +- **THEN** DimOS validates the joint-name set and reorders limits by name before exposing them through DimOS APIs + +### Requirement: Unsupported selections fail without partial planning +`plan_selected_joint_path` SHALL return `PlanningStatus.UNSUPPORTED` for selections that cannot be represented by a single generated RoboPlan group. + +#### Scenario: Overlapping groups are selected +- **WHEN** a selection contains overlapping joints or groups that were not generated as one Composite planning group +- **THEN** `plan_selected_joint_path` returns `PlanningStatus.UNSUPPORTED` and does not invoke RoboPlan RRT + +### Requirement: Dynamic obstacles remain scene state +Dynamic obstacles SHALL remain Planning world and RoboPlan scene state, not baked into the generated composite URDF or SRDF. + +#### Scenario: Obstacle changes after finalization +- **WHEN** obstacle geometry changes after RoboPlan world finalization +- **THEN** the change is represented through RoboPlan scene/world state updates rather than by regenerating the composite URDF/SRDF diff --git a/openspec/specs/roboplan-planning-group-native-order/spec.md b/openspec/specs/roboplan-planning-group-native-order/spec.md new file mode 100644 index 0000000000..27504464af --- /dev/null +++ b/openspec/specs/roboplan-planning-group-native-order/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: RoboPlan registers configured planning groups +`RoboPlanWorld` SHALL register planning groups from `RobotModelConfig.planning_groups` using the existing public planning-group registry model. + +#### Scenario: Planning group metadata is validated against RoboPlan +- **WHEN** a robot is added to `RoboPlanWorld` +- **THEN** the world SHALL query RoboPlan group metadata for each configured planning group +- **AND** the world SHALL validate that the RoboPlan native joint-name set matches the configured planning group's local joint-name set + +#### Scenario: Native joint order is preserved as adapter metadata +- **WHEN** RoboPlan returns a planning group's joint names in a different order than the configured public planning group +- **THEN** the world SHALL preserve the RoboPlan native order separately from the public planning group order +- **AND** the world SHALL use name-based conversion at RoboPlan boundaries + +### Requirement: RoboPlan SRDF handling is explicit and narrow +`RoboPlanWorld` SHALL either use a caller-provided SRDF or generate only a simple SRDF for a single configured planning group. + +#### Scenario: Provided SRDF is used directly +- **WHEN** `RobotModelConfig.srdf_path` is set +- **THEN** `RoboPlanWorld` SHALL pass that SRDF path to RoboPlan scene construction +- **AND** `RoboPlanWorld` SHALL validate configured planning groups against the RoboPlan scene + +#### Scenario: Generated SRDF supports one configured group +- **WHEN** `RobotModelConfig.srdf_path` is not set and exactly one planning group is configured +- **THEN** `RoboPlanWorld` SHALL generate an SRDF containing that planning group's name and joints +- **AND** the generated SRDF SHALL retain configured and adjacent-link collision exclusions + +#### Scenario: Multi-group generated SRDF is rejected +- **WHEN** `RobotModelConfig.srdf_path` is not set and more than one planning group is configured +- **THEN** `RoboPlanWorld` SHALL reject the robot configuration with a clear error instructing the caller to provide `RobotModelConfig.srdf_path` + +### Requirement: RoboPlan world belief remains full robot local state +`RoboPlanContext` SHALL keep robot state in full robot-local joint order while planning-group operations project from that state by name. + +#### Scenario: Live joint state sync stores full robot state +- **WHEN** a named driver `JointState` is synced into `RoboPlanWorld` +- **THEN** the world SHALL store the robot state ordered by `RobotModelConfig.joint_names` + +#### Scenario: Group operations project from full robot state +- **WHEN** a RoboPlan group operation needs a group vector +- **THEN** the world SHALL project the full robot-local vector into RoboPlan native group order by joint name + +### Requirement: RoboPlan native planner supports selected planning groups +`RoboPlanWorld` SHALL implement `PlannerSpec.plan_selected_joint_path(...)` for one selected planning group using RoboPlan native group order. + +#### Scenario: Selected group plan uses RoboPlan native order +- **WHEN** `plan_selected_joint_path(...)` is called with one supported planning group and valid global start and goal joint states +- **THEN** `RoboPlanWorld` SHALL convert start and goal into RoboPlan native group order +- **AND** it SHALL call RoboPlan RRT with `RRTOptions.group_name` set to the selected group name +- **AND** it SHALL construct RoboPlan `JointConfiguration` values with native joint names and native-order positions + +#### Scenario: Selected group plan returns public order +- **WHEN** RoboPlan returns a successful native path +- **THEN** `RoboPlanWorld` SHALL return `PlanningResult.path` as `JointState` waypoints named with the selected group's public global joint names +- **AND** waypoint positions SHALL be ordered by the public selection order + +#### Scenario: Unsupported selections fail explicitly +- **WHEN** `plan_selected_joint_path(...)` is called with multiple planning groups, multiple robots, or a selection that does not exactly match one configured group +- **THEN** `RoboPlanWorld` SHALL return `PlanningStatus.UNSUPPORTED` with an explanatory message + +### Requirement: RoboPlan group FK and Jacobian use selected group semantics +`RoboPlanWorld` SHALL implement group-scoped FK and Jacobian methods using the selected planning group's target frame and native joint order. + +#### Scenario: Group FK uses target frame +- **WHEN** `get_group_ee_pose(ctx, group_id)` is called for a group with a target frame +- **THEN** `RoboPlanWorld` SHALL project context state into the group's native order +- **AND** it SHALL query RoboPlan forward kinematics for the group's target frame + +#### Scenario: Group Jacobian uses target frame and group columns +- **WHEN** `get_group_jacobian(ctx, group_id)` is called for a group with a target frame +- **THEN** `RoboPlanWorld` SHALL return a 6xN Jacobian whose columns correspond to the public group's local joint order + +### Requirement: RoboPlan exposes only current group-oriented planning contracts +`RoboPlanWorld` SHALL implement the current group-oriented `PlannerSpec` and `WorldSpec` surface without adding RoboPlan-specific legacy compatibility wrappers. + +#### Scenario: Factory validates selected-group planner contract +- **WHEN** RoboPlan is selected as a planner backend +- **THEN** planner creation SHALL validate support for `plan_selected_joint_path(...)` +- **AND** it SHALL not rely on RoboPlan-specific `plan_joint_path(...)` compatibility behavior + +#### Scenario: Protocol validation does not force RoboPlan legacy wrappers +- **WHEN** protocol or factory validation is updated for RoboPlan +- **THEN** RoboPlan SHALL be validated through the group-oriented planner/world methods used by the current codebase +- **AND** RoboPlan SHALL not be required to keep robot-scoped compatibility methods solely to satisfy stale validation + +#### Scenario: Group world queries are the RoboPlan FK and Jacobian interface +- **WHEN** callers need RoboPlan FK or Jacobian data +- **THEN** callers SHALL use `get_group_ee_pose(...)` or `get_group_jacobian(...)` +- **AND** the RoboPlan adapter SHALL not add robot-scoped FK/Jacobian compatibility requirements beyond the current `WorldSpec` diff --git a/pyproject.toml b/pyproject.toml index c2d9b5a60f..fb556b37ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -592,6 +592,7 @@ ignore = [ "dimos/web/dimos_interface/themes.json", "dimos/manipulation/manipulation_module.py", "dimos/manipulation/planning/world/drake_world.py", + "dimos/manipulation/planning/world/roboplan_world.py", "dimos/manipulation/visualization/viser/test_viser_visualization.py", "dimos/navigation/cmu_nav/modules/*/main.cpp", "dimos/navigation/cmu_nav/common/*.hpp", From e35744e4d25f4732d65def8ba58e9db4e02b388e Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 24 Jun 2026 19:09:11 -0700 Subject: [PATCH 076/110] spec: behavior plan --- .../drafts/behavior-benchmark-integration.md | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 openspec/drafts/behavior-benchmark-integration.md diff --git a/openspec/drafts/behavior-benchmark-integration.md b/openspec/drafts/behavior-benchmark-integration.md new file mode 100644 index 0000000000..d109751f46 --- /dev/null +++ b/openspec/drafts/behavior-benchmark-integration.md @@ -0,0 +1,438 @@ +# Draft Plan: BEHAVIOR Benchmark Integration + +Status: exploratory draft, not an active OpenSpec change +Date: 2026-06-24 + +## Goal + +Integrate Stanford BEHAVIOR / OmniGibson as an agentic benchmarking target for DimOS while keeping BEHAVIOR's heavy simulator dependencies isolated from the normal DimOS development environment. + +The first useful milestone is not full manipulation-stack integration. It is: + +> BEHAVIOR evaluator can run reproducibly, call a DimOS-controlled policy, and produce parseable metrics, JSON logs, and rollout videos. + +After that works, the integration can climb toward DimOS agents, skills, the control coordinator, and eventually manipulation planning. + +## Core Architecture + +Treat BEHAVIOR as an external benchmark runtime connected through its official websocket policy interface. + +```text +┌──────────────────────────────┐ +│ BEHAVIOR / OmniGibson env │ +│ Isaac Sim + tasks + metrics │ +│ │ +│ eval.py policy=websocket │ +└──────────────┬───────────────┘ + │ obs dict + ▼ +┌──────────────────────────────┐ +│ DimOS BEHAVIOR policy bridge │ +│ websocket server/client shim │ +└──────────────┬───────────────┘ + │ normalized obs / action request + ▼ +┌──────────────────────────────┐ +│ DimOS agentic stack │ +│ agent / skills / planner / │ +│ coordinator adapters │ +└──────────────┬───────────────┘ + │ action tensor + ▼ +┌──────────────────────────────┐ +│ BEHAVIOR evaluator │ +│ rollout JSON + mp4 + score Q │ +└──────────────────────────────┘ +``` + +## BEHAVIOR Facts That Shape the Plan + +- BEHAVIOR uses OmniGibson. +- OmniGibson runs on Isaac Sim / Omniverse / PhysX. +- OmniGibson is a synchronous, MDP-style simulator interface layered over Isaac Sim. +- BEHAVIOR tasks are defined in BDDL with object scope, initial conditions, and goal conditions. +- BEHAVIOR evaluation supports a websocket policy mode: + +```bash +python OmniGibson/omnigibson/learning/eval.py \ + policy=websocket \ + log_path=$LOG_PATH \ + task.name=$TASK_NAME \ + env_wrapper._target_=$WRAPPER_MODULE +``` + +- Default wrapper is `omnigibson.learning.wrappers.RGBLowResWrapper`. +- Other documented wrappers include `DefaultWrapper`, `HeavyRobotWrapper`, and `RichObservationWrapper`. +- Primary metric is task success score `Q`. +- Partial success is satisfied goal predicates divided by total goal predicates. +- Evaluation convention is 1 rollout per task instance, commonly first 10 extra instances per task for self-evaluation. +- Full evaluation can produce up to 500 JSON/video outputs for 50 tasks × 10 instances. + +## Dependency Strategy + +Do not add OmniGibson / Isaac Sim / BEHAVIOR as normal DimOS dependencies. + +Use three layers: + +### 1. DimOS Core Environment + +Normal `uv` environment. Should stay free of direct Isaac Sim / OmniGibson imports. + +### 2. BEHAVIOR Environment + +External conda environment, likely named `behavior`, created by the BEHAVIOR setup script. + +BEHAVIOR documented requirements: + +| Requirement | Value | +|---|---| +| OS | Ubuntu 20.04+ or Windows 10+ | +| RAM | 32GB+ | +| GPU | NVIDIA RTX 2070+ | +| VRAM | 8GB+ | +| Runtime | OmniGibson on Isaac Sim / Omniverse | + +Expected setup shape: + +```bash +git clone -b v3.7.2 https://github.com/StanfordVL/BEHAVIOR-1K.git +cd BEHAVIOR-1K +./setup.sh --new-env --omnigibson --bddl --dataset --eval \ + --accept-conda-tos --accept-nvidia-eula --accept-dataset-tos +conda activate behavior +``` + +Known install/runtime gotchas: + +- First OmniGibson import can take several minutes. +- CuRobo/primitives can need matching CUDA toolkit/compiler configuration. +- Renderer failure like `HydraEngine rtx failed creating scene renderer` can indicate the wrong GPU; use `OMNIGIBSON_GPU_ID=`. +- Docker installation is documented as temporarily unavailable for full simulator setup. +- BEHAVIOR's challenge Docker policy mode should not launch Isaac Sim inside the policy container; the evaluator runs OmniGibson outside and connects by websocket. + +### 3. Bridge Protocol + +The stable boundary should be websocket messages plus file-based metrics outputs: + +- observation dict from BEHAVIOR evaluator +- action tensor/vector returned to BEHAVIOR +- reset / episode lifecycle events +- timeout and reconnect behavior +- log path for JSON and MP4 outputs + +## Current DimOS Integration Seams + +Relevant repo seams identified during exploration: + +- `dimos/manipulation/manipulation_module.py` + - `execute_plan()` projects a `GeneratedPlan` into per-robot `JointTrajectory` objects. + - It dispatches execution through coordinator `task_invoke(task_name, "execute", {"trajectory": trajectory})`. + - It assumes current joint state, global joint names, local robot joint names, and trajectory generation. + +- `dimos/control/coordinator.py` + - `task_invoke(task_name, method, kwargs)` is the stable control RPC seam. + - Also exposes task lifecycle/introspection methods like `list_tasks()` and `get_active_tasks()`. + +- `dimos/control/tasks/trajectory_task/trajectory_task.py` + - `execute(trajectory)` validates duration, points, and exact joint-name compatibility. + +- `dimos/robot/manipulators/xarm/blueprints/simulation.py` + - Existing sim pattern composes `MujocoSimModule`, `PickAndPlaceModule`, `ObjectSceneRegistrationModule`, `ControlCoordinator`, and trajectory tasks. + +- `dimos/navigation/nav_3d/evaluator/evaluator.py` + - Useful evaluator-loop pattern: publish scenario inputs, wait for outputs, log results. + +## Phased Plan + +### Phase 0: BEHAVIOR Dependency Spike + +Purpose: prove BEHAVIOR can run independently on target hardware. + +Tasks: + +- Install BEHAVIOR into an isolated conda environment. +- Verify Isaac Sim / OmniGibson startup. +- Run a minimal OmniGibson robot example. +- Run BEHAVIOR evaluation with a dummy websocket policy. +- Record exact compatibility matrix: + - OS + - Python version + - Isaac Sim version + - OmniGibson / BEHAVIOR branch or tag + - CUDA toolkit/compiler version + - NVIDIA driver and GPU + +Success criterion: + +```bash +conda activate behavior +python -m omnigibson.examples.robots.robot_control_example --quickstart +python OmniGibson/omnigibson/learning/eval.py \ + policy=websocket \ + log_path=/tmp/behavior_eval \ + task.name=turning_on_radio +``` + +with a dummy policy producing legal actions and evaluator outputs. + +### Phase 1: Black-Box Evaluator Runner + +Purpose: make DimOS able to supervise BEHAVIOR runs without importing OmniGibson. + +Responsibilities: + +- Start a policy bridge process. +- Launch BEHAVIOR `eval.py` as a subprocess in the BEHAVIOR conda env. +- Pass task name, wrapper, log path, websocket host/port, and config overrides. +- Wait for completion. +- Collect stdout/stderr, JSON metrics, and MP4 videos. +- Produce a run summary. + +Conceptual run config: + +```yaml +benchmark: behavior +behavior_root: /path/to/BEHAVIOR-1K +conda_env: behavior +tasks: + - turning_on_radio +wrapper: omnigibson.learning.wrappers.RGBLowResWrapper +policy_backend: zero +instances: first_10 +log_path: /tmp/dimos_behavior_runs/run_001 +port: 8080 +max_steps: null +``` + +### Phase 2: Policy Bridge + +Purpose: translate BEHAVIOR observations and action requests into DimOS policy calls. + +Observation path: + +```text +BEHAVIOR obs dict + ├── rgb / depth / segmentation depending on wrapper + ├── proprioception + ├── need_new_action + └── optional task or privileged info + ▼ +DimOS normalized observation + ▼ +DimOS policy backend +``` + +Action path: + +```text +DimOS policy decision + ▼ +BehaviorActionAdapter + ▼ +BEHAVIOR action tensor/vector +``` + +The bridge should preserve BEHAVIOR websocket policy semantics: + +- `obs["need_new_action"] == False` can reuse the last action. +- Reset should clear cached action and policy state. +- Host/port should be configurable; avoid default port 80. +- Reconnect/timeout behavior should be explicit. + +### Phase 3: Baseline Policies + +Purpose: validate benchmark plumbing before involving LLMs or planning. + +Suggested baselines: + +- zero action policy +- random legal action policy +- scripted smoke policy for one simple task +- trace-recording policy that logs observation schema and action dimensions + +These establish action-space correctness, evaluator stability, and metrics ingestion. + +### Phase 4: DimOS Agent Backend + +Purpose: run an agentic DimOS policy inside the benchmark loop. + +Likely shape: + +```text +BEHAVIOR evaluator + ⇄ websocket +BehaviorPolicyBridge + ⇄ DimOS agent backend + ⇄ skills / perception summaries / planner calls +``` + +Important concerns: + +- LLM latency versus simulator action frequency. +- Use cached actions or low-level controllers between high-level decisions. +- Record agent traces alongside BEHAVIOR metrics. +- Keep wrapper/track explicit: RGB-only, standard observations, or privileged observations. + +### Phase 5: Control Coordinator Adapter + +Purpose: optionally reuse DimOS control tasks and coordinator semantics. + +Two possible adapter styles: + +#### Option A: Direct Action-Space Adapter + +DimOS policy emits high-level intent or controller targets, and adapter directly returns BEHAVIOR action vectors. + +Pros: + +- fastest path to valid benchmark runs +- avoids pretending BEHAVIOR is the existing xArm/MuJoCo stack +- keeps evaluator-compatible action semantics central + +Cons: + +- less reuse of `ControlCoordinator` +- weaker comparison with existing trajectory execution stack + +#### Option B: Virtual Hardware Adapter + +Represent BEHAVIOR robot control channels as DimOS hardware/components and route through `ControlCoordinator`. + +```text +ControlCoordinator + ├── base task + ├── torso task + ├── left arm task + ├── right arm task + └── gripper tasks + ▼ +BehaviorHardwareAdapter + ▼ +BEHAVIOR action tensor +``` + +Pros: + +- reuses coordinator/task architecture +- could support `task_invoke`, `get_joint_positions`, and trajectory tasks +- aligns with existing manipulation abstractions + +Cons: + +- substantial joint/action mapping work +- BEHAVIOR robot/controller config must be pinned +- exact joint names and dimensions are critical +- synchronous policy stepping differs from normal robot control loops + +Recommendation: start with Option A, then evolve toward Option B if benchmarked algorithms need coordinator semantics. + +### Phase 6: ManipulationModule / Planner Integration + +Purpose: bring current manipulation planning into BEHAVIOR once the benchmark loop and action bridge are proven. + +This is the hardest phase. Current `ManipulationModule.execute_plan()` assumes: + +```text +GeneratedPlan + └── path: list[JointState] + ├── global joint names + └── positions +``` + +BEHAVIOR requires additional bridges: + +| Needed bridge | Challenge | +|---|---| +| BEHAVIOR objects → DimOS world monitor | IDs, poses, categories, articulations | +| BDDL goals → DimOS skill/planning goals | symbolic predicates vs manipulation primitives | +| BEHAVIOR robot model → DimOS robot config | R1Pro/mobile manipulator differs from xArm | +| GeneratedPlan → BEHAVIOR action tensor | trajectory timing and controller mismatch | +| Base/torso/dual-arm/gripper coordination | action vector must encode all channels | + +Do not make this the first integration target. + +## Benchmark Output Layout + +Suggested DimOS-managed layout: + +```text +runs/ + behavior/ + 2026-06-24-/ + config.yaml + env.json + stdout.log + stderr.log + tasks/ + turning_on_radio/ + rollout_000.json + rollout_000.mp4 + ... + summary.json + summary.md +``` + +Summary should include: + +- task names +- instance IDs / seeds / config indices +- wrapper +- policy backend +- action-space config +- score `Q` +- partial success predicates +- simulated time +- base distance +- end-effector displacement +- timeout or failure reason +- links to videos + +## Risks and Mitigations + +| Risk | Concern | Mitigation | +|---|---|---| +| Isaac Sim dependency weight | normal CI/dev env cannot run it | isolate in conda env and self-hosted GPU path | +| Port 80 default | root permission / conflicts | override websocket config to high port | +| Robot mismatch | DimOS xArm stack differs from BEHAVIOR R1Pro | start with action tensor adapter | +| LLM latency | policy may be slower than simulator loop | cache actions, decouple high-level decisions from low-level control | +| Observation mismatch | different wrappers imply different benchmark tracks | pin wrapper and label standard vs privileged track | +| Action-space mismatch | controller config determines action dimensions | pin robot controller config and record it in run metadata | +| Metrics reproducibility | task instances/seeds matter | record task, instance id, config overrides, env version | +| ManipulationModule assumptions | expects joint trajectory plans | defer until object/world/robot adapters exist | + +## Things Not To Do First + +- Do not add OmniGibson as a normal DimOS dependency. +- Do not treat BEHAVIOR as a drop-in `MujocoSimModule` replacement. +- Do not force BEHAVIOR through `ManipulationModule.execute_plan()` before action and world adapters exist. +- Do not hide adapters outside the benchmarked path. +- Do not mix RGB-only, standard, and privileged wrappers without explicit labels. + +## Candidate Future OpenSpec Change + +If this becomes implementation work, create a change such as: + +```text +add-behavior-benchmark-integration +``` + +Likely artifact scope: + +1. Dependency isolation and runbook +2. Evaluator runner +3. Websocket policy bridge +4. Baseline policies +5. Result ingestion and summaries +6. DimOS agent backend +7. Future coordinator/manipulation adapter design + +## Open Questions + +- Which BEHAVIOR version/tag should be the initial target: stable release `v3.7.2` or main? +- Which GPU machines will run dependency spike and future benchmarks? +- Should first DimOS policy bridge run inside the BEHAVIOR conda env or outside it with a reimplemented websocket protocol? +- Which wrapper should be the default benchmark mode: `RGBLowResWrapper`, `DefaultWrapper`, or custom? +- Do we care first about challenge-compatible scoring, internal development benchmarking, or both? +- What level of agentic control is the first benchmark target: direct action policy, skill policy, coordinator-backed policy, or full manipulation planner? +- How should BEHAVIOR observations be persisted for debugging without creating huge logs? +- What is the minimum smoke task that can verify a non-zero action policy? From ef22052cf10df94946302a1a8f0ab6c19e78c4de Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 25 Jun 2026 15:57:40 -0700 Subject: [PATCH 077/110] dep: grasp module --- CONTEXT.md | 20 + .../drafts/agentic-skill-benchmark-harness.md | 463 ++++++++++++++++++ pyproject.toml | 19 +- uv.lock | 63 ++- 4 files changed, 562 insertions(+), 3 deletions(-) create mode 100644 openspec/drafts/agentic-skill-benchmark-harness.md diff --git a/CONTEXT.md b/CONTEXT.md index 0d54a33bb1..f5948c5762 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -19,3 +19,23 @@ _Avoid_: combined URDF, merged robot scene **Planning world**: The authoritative belief state for manipulation planning, including robot state and scene state used by planners. _Avoid_: planner context, backend instance + +**Coordinated simulation clock**: +A simulation benchmark clock policy where simulator time advances in lockstep with the DimOS control coordinator clock. +_Avoid_: autonomous simulator loop, write-triggered stepping, settle step + +**Runtime sidecar**: +A separate process or environment that owns a benchmark simulator backend while DimOS owns orchestration, control integration, skills, and artifacts. +_Avoid_: plugin, embedded simulator + +**Depth observation**: +A depth image interpreted with its camera calibration and the pose of its camera frame at the image timestamp. +_Avoid_: depth point cloud, raw 3D points + +**SHM runtime data plane**: +A shared-memory command/state channel between a simulated hardware adapter and a simulator runtime, used when high-rate control must cross process boundaries without RPC. +_Avoid_: public simulator API, benchmark control plane, module object sharing + +**Motor state projection**: +The hardware-facing subset of simulator state that resembles what a raw robot driver exposes: actuator positions, velocities, efforts, commands, enable state, and errors. +_Avoid_: task observation, scene observation, evaluator state diff --git a/openspec/drafts/agentic-skill-benchmark-harness.md b/openspec/drafts/agentic-skill-benchmark-harness.md new file mode 100644 index 0000000000..ff55841523 --- /dev/null +++ b/openspec/drafts/agentic-skill-benchmark-harness.md @@ -0,0 +1,463 @@ +# Draft Plan: Agentic Skill Benchmark Harness + +Status: exploratory draft, not an active OpenSpec change +Date: 2026-06-24 + +## Goal + +Build a DimOS-native benchmark harness for evaluating agents that solve robotics tasks by calling existing skills. + +This is intentionally **not** a code-as-policy benchmark. The unit under test is: + +```text +LLM agent + system prompt + available MCP skills + DimOS blueprint + task scenario +``` + +The first milestone should prove that DimOS can run reproducible skill-calling episodes, score the final world state externally, and archive enough artifacts to debug failures. + +## Non-Goals + +- Do not execute model-generated Python code. +- Do not clone Cap-X's `ApiBase.functions()` / code-action interface yet. +- Do not introduce a large benchmark taxonomy in the first milestone. +- Do not require Ray, sandboxing, or parallel rollout infrastructure initially. +- Do not expose raw geometry helper APIs unless a pilot task needs them. + +## Core Episode Loop + +```text +┌──────────────────────────────┐ +│ Agentic benchmark task spec │ +│ prompt / blueprint / skills │ +│ reset / timeout / scorer │ +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ Reset scenario │ +│ sim, replay, or real setup │ +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ Start DimOS blueprint │ +│ modules + MCP server/client │ +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ Send task prompt to agent │ +│ agent calls allowed skills │ +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ Observe outcome externally │ +│ streams / RPC / sim state │ +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ Score + archive artifacts │ +│ score.json / tool calls / logs│ +└──────────────────────────────┘ +``` + +## Task Spec Shape + +The task spec should be declarative enough to run the same task across agents and models. + +Conceptual YAML: + +```yaml +name: pick-cup-place-on-marker +suite: tabletop-manipulation +blueprint: xarm-perception-sim-agent + +prompt: | + Find the cup and place it on the red marker. + +skill_profile: manipulation_basic +allowed_skills: + - get_scene_info + - look + - scan_objects + - pick + - place + - place_back + - drop_on + - open_gripper + - close_gripper + +limits: + timeout_s: 180 + max_tool_calls: 20 + +reset: + type: simulation_scene + scene: tabletop_basic + seed: 1 + +success: + type: object_near_target + object: cup + target: red_marker + radius_m: 0.08 + +artifacts: + record_agent_messages: true + record_tool_calls: true + record_module_logs: true + record_final_observation: true + record_rerun: true +``` + +## Skill Profiles + +Skill profiles are benchmark contracts over existing DimOS MCP tools. They make evaluation fair by controlling what the agent can call. + +### `manipulation_basic` + +Initial profile for tabletop manipulation tasks: + +- `get_scene_info(robot_name=None)` +- `look(robot_name=None)` +- `scan_objects(min_duration=0.0, robot_name=None)` +- `pick(object_name, object_id=None, robot_name=None)` +- `place(x, y, z, robot_name=None)` +- `place_back(robot_name=None)` +- `drop_on(target_object_name, z_offset=0.1, robot_name=None)` +- `open_gripper(robot_name=None)` +- `close_gripper(robot_name=None)` +- `move_to_pose(x, y, z, roll=None, pitch=None, yaw=None, robot_name=None)` +- `reset()` + +Candidate source modules: + +- `dimos/manipulation/manipulation_module.py` +- `dimos/manipulation/pick_and_place_module.py` + +### `navigation_basic` + +Future profile for mobile robot tasks: + +- `navigate_with_text(query)` +- `tag_location(location_name)` +- `stop_navigation()` +- `relative_move(forward=0.0, left=0.0, degrees=0.0)` +- `wait(seconds)` + +Candidate source modules: + +- `dimos/agents/skills/navigation.py` +- `dimos/robot/unitree/unitree_skill_container.py` + +### `drone_basic` + +Future profile for aerial tasks: + +- `takeoff(altitude)` +- `land()` +- `move(x, y, z, duration)` +- `fly_to(lat, lon, alt)` +- `follow_object(object_description, duration)` +- `observe()` + +Candidate source module: + +- `dimos/robot/drone/connection_module.py` + +## Observation Model + +For the first milestone, observation should be **agent-facing**, not tensor-first. + +The agent should observe through existing skills such as: + +- `get_scene_info()` +- `look()` +- `scan_objects()` +- `get_robot_state()` +- `observe()` for drone tasks + +The harness may also collect a separate evaluator-facing snapshot for scoring: + +```text +EvaluatorObservation + - robot pose / joint state + - end-effector pose + - gripper state + - detected objects + - simulator ground truth, when available + - fault / task state +``` + +The evaluator-facing snapshot should not automatically be exposed to the agent. + +## Scoring Principles + +The agent must not self-report success. Success is computed by the harness from world state, module state, simulator state, or trusted evaluator RPCs. + +Example scorer types: + +### Manipulation + +- object detected +- object lifted above height threshold +- object within radius of target pose +- object near or on another object +- gripper empty/full after task +- robot/module not in fault state + +### Navigation + +- robot pose within radius of target +- visited required waypoint sequence +- target person/object followed for minimum duration +- no timeout or navigation failure + +### Drone + +- reached GPS/relative pose target +- maintained object in frame for minimum duration +- landed safely +- no disarm/failsafe violation + +## Trial Artifacts + +Each trial should emit a self-contained artifact directory. + +```text +trial_/ + task.yaml + resolved_config.yaml + prompt.txt + available_skills.json + agent_messages.jsonl + tool_calls.jsonl + skill_results.jsonl + module_logs.jsonl + final_observation.json + score.json + rerun.rrd or video.mp4 +``` + +`score.json` should include at least: + +```json +{ + "success": true, + "score": 1.0, + "reason": "cup within 0.08m of red_marker", + "timeout": false, + "tool_calls": 7, + "duration_s": 93.4 +} +``` + +## MVP Suite: Tabletop Manipulation + +Start with a small suite that exercises actual agent skill choice, retry behavior, and interpretation of tool results. + +### Task 1: `scan-visible-object` + +Prompt: + +```text +Find the cup in the scene. +``` + +Success: + +- target object appears in detection snapshot or evaluator-visible object list. + +Purpose: + +- validates observation skills and basic agent-tool loop. + +### Task 2: `pick-object` + +Prompt: + +```text +Pick up the cup. +``` + +Success: + +- cup is lifted above a threshold or held by gripper according to evaluator state. + +Purpose: + +- validates `scan_objects`, `pick`, and recovery from missing object IDs. + +### Task 3: `place-at-coordinate` + +Prompt: + +```text +Place the cup at the marked target position. +``` + +Success: + +- cup is within radius of the target pose. + +Purpose: + +- validates pick/place sequencing and spatial instruction following. + +### Task 4: `drop-on-object` + +Prompt: + +```text +Put the block on the tray. +``` + +Success: + +- block is near or above tray region. + +Purpose: + +- validates object-to-object spatial reasoning through existing high-level skills. + +### Task 5: `recover-after-failed-pick` + +Prompt: + +```text +Pick up the cup. If the first attempt fails, inspect the scene and try again. +``` + +Success: + +- object eventually held or lifted within tool/time budget. + +Purpose: + +- validates agent recovery behavior and skill-result interpretation. + +## Implementation Phases + +### Phase 0: Harness Design Spike + +Purpose: finalize the declarative task schema and identify current repo seams. + +Tasks: + +- Confirm how to launch/stop target blueprints in-process or through `dimos run`. +- Confirm how to send prompts to an existing agent, likely reusing `dimos/agents/agent_test_runner.py` patterns. +- Confirm where MCP tool calls and agent messages can be captured. +- Define a minimal scorer interface. +- Define artifact directory layout. + +Output: + +- schema draft for `AgenticBenchmarkTask` +- first task YAML examples +- scorer interface sketch + +### Phase 1: Black-Box Agent Runner + +Purpose: run one prompt against one blueprint and collect artifacts. + +Responsibilities: + +- start or connect to a blueprint +- wait for MCP/agent readiness +- apply skill profile filtering, if supported +- send task prompt +- wait for completion, timeout, or max tool calls +- collect messages, tool calls, skill results, logs + +Success criterion: + +- one manually configured task produces a complete artifact directory and pass/fail score. + +### Phase 2: Manipulation Scorers + +Purpose: score the MVP tabletop suite externally. + +Initial scorer interface: + +```text +score(task, final_observation, artifact_dir) -> ScoreResult +``` + +Candidate scorer implementations: + +- `object_detected` +- `object_lifted` +- `object_near_pose` +- `object_near_object` +- `module_not_faulted` + +Success criterion: + +- the five MVP manipulation tasks can be scored without asking the agent whether it succeeded. + +### Phase 3: Batch Runs and Summaries + +Purpose: run repeatable evaluations over tasks, seeds, and model configs. + +Responsibilities: + +- run multiple tasks sequentially +- aggregate results into `summary.json` and Markdown report +- preserve per-trial artifacts +- support reruns with the same seed/config + +Success criterion: + +- a suite run emits pass rate, average duration, average tool calls, failure reasons, and links to artifacts. + +### Phase 4: Broaden Profiles + +Purpose: reuse the harness for non-manipulation embodied tasks. + +Candidate suites: + +- Go2 navigation and person-following tasks +- drone observe/follow/fly-to tasks +- mixed perception/navigation tasks + +## Key Design Questions + +1. Should skill filtering happen by MCP server configuration, MCP client prompt/tool list filtering, or harness-side validation of tool calls? +2. What is the minimum reliable signal for “agent done” across current DimOS agents? +3. Should reset be required for all tasks, or can early tasks support `setup`/`teardown` only? +4. For simulated manipulation, where should ground-truth object poses come from? +5. Should scorer code call DimOS RPCs directly, read streams, or parse module outputs? +6. How strict should max-tool-call limits be for recovery-oriented tasks? + +## Risks + +- Existing skills return human-readable strings; scorers need structured state from modules or simulator, not just skill output text. +- Skill availability is currently blueprint-derived; task-scoped skill profiles may need a filtering layer. +- Reset semantics may be inconsistent across replay, simulation, and real hardware. +- Manipulation success may require simulator ground truth or reliable perception snapshots. +- Agent completion detection may need tighter integration than simple timeout-based waiting. + +## Recommended First Milestone + +Implement the smallest useful loop: + +```text +one manipulation sim blueprint +one task YAML +one prompt +one skill profile +one external scorer +one artifact directory +``` + +Suggested first task: + +```text +scan-visible-object +``` + +Reason: it validates benchmark plumbing before adding manipulation execution complexity. + +After that passes, move to `pick-object`, then `place-at-coordinate`. diff --git a/pyproject.toml b/pyproject.toml index fb556b37ac..ae77e172d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -262,6 +262,17 @@ manipulation = [ "pyyaml>=6.0", ] +grasp = [ + # Experimental learned grasp proposal backend. VGN's catkin-style package + # metadata omits most Python runtime dependencies, so list the import-time + # dependencies needed by `vgn.detection`/`vgn.perception` here. + "vgn", + "catkin_pkg>=1.1.0", + "matplotlib>=3.7.1", + "torch", + "pytorch-ignite", +] + cpu = [ # CPU inference backends "onnxruntime", @@ -314,7 +325,7 @@ apriltag = [ ] all = [ - "dimos[agents,apriltag,base,cpu,cuda,drone,manipulation,misc,perception,sim,unitree,visualization,web]", + "dimos[agents,apriltag,base,cpu,cuda,drone,grasp,manipulation,misc,perception,sim,unitree,visualization,web]", ] [dependency-groups] @@ -440,6 +451,12 @@ override-dependencies = [ "importlib-metadata<8.8.0", ] +[tool.uv.sources] +vgn = { git = "https://github.com/ethz-asl/vgn.git", rev = "d7af0622433f52ae88ebe81533f12b46b33e951a" } + +[tool.uv.extra-build-dependencies] +vgn = ["catkin_pkg>=1.1.0"] + [tool.ruff] line-length = 100 exclude = [ diff --git a/uv.lock b/uv.lock index a22bbafc62..976027e249 100644 --- a/uv.lock +++ b/uv.lock @@ -711,6 +711,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, ] +[[package]] +name = "catkin-pkg" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "packaging" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/7a/dcd7ba56dc82d88b3059a6770828388fc2e136ca4c5d79003f9febf33087/catkin_pkg-1.1.0.tar.gz", hash = "sha256:df1cb6879a3a772e770a100a6613ce8fc508b4855e5b2790106ddad4a8beb43c", size = 65547, upload-time = "2025-09-10T17:34:36.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/1b/50316bd6f95c50686b35799abebb6168d90ee18b7c03e3065f587f010f7c/catkin_pkg-1.1.0-py3-none-any.whl", hash = "sha256:7f5486b4f5681b5f043316ce10fc638c8d0ba8127146e797c85f4024e4356027", size = 76369, upload-time = "2025-09-10T17:34:35.639Z" }, +] + [[package]] name = "cattrs" version = "25.3.0" @@ -2014,6 +2030,7 @@ agents = [ ] all = [ { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "catkin-pkg" }, { name = "chromadb" }, { name = "cupy-cuda12x", marker = "platform_machine == 'x86_64'" }, { name = "dimos-viewer" }, @@ -2056,6 +2073,7 @@ all = [ { name = "pymavlink" }, { name = "pyrealsense2-extended", marker = "sys_platform != 'darwin'" }, { name = "python-multipart" }, + { name = "pytorch-ignite" }, { name = "pyyaml" }, { name = "qpsolvers", extra = ["proxqp"] }, { name = "reportlab" }, @@ -2065,12 +2083,14 @@ all = [ { name = "sse-starlette" }, { name = "tensorboard" }, { name = "timm" }, + { name = "torch" }, { name = "torchreid" }, { name = "transformers", extra = ["torch"] }, { name = "trimesh" }, { name = "ultralytics" }, { name = "unitree-webrtc-connect" }, { name = "uvicorn" }, + { name = "vgn" }, { name = "viser", extra = ["urdf"] }, { name = "xacro" }, { name = "xarm-python-sdk" }, @@ -2120,6 +2140,13 @@ dds = [ drone = [ { name = "pymavlink" }, ] +grasp = [ + { name = "catkin-pkg" }, + { name = "matplotlib" }, + { name = "pytorch-ignite" }, + { name = "torch" }, + { name = "vgn" }, +] manipulation = [ { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "drake", version = "1.45.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, @@ -2405,12 +2432,13 @@ requires-dist = [ { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'" }, { name = "annotation-protocol", specifier = ">=1.4.0" }, { name = "bleak", specifier = ">=3.0.2" }, + { name = "catkin-pkg", marker = "extra == 'grasp'", specifier = ">=1.1.0" }, { name = "chromadb", marker = "extra == 'perception'", specifier = ">=1.0.0" }, { name = "cryptography", specifier = ">=46.0.5" }, { name = "cupy-cuda12x", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = "==13.6.0" }, { name = "cyclonedds", marker = "extra == 'dds'", specifier = ">=0.10.5" }, { name = "cyclonedds", marker = "extra == 'unitree-dds'", specifier = ">=0.10.5" }, - { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "manipulation", "misc", "perception", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, + { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "grasp", "manipulation", "misc", "perception", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, { name = "dimos", extras = ["agents", "web", "perception", "visualization"], marker = "extra == 'base'" }, { name = "dimos", extras = ["base", "mapping"], marker = "extra == 'unitree'" }, { name = "dimos", extras = ["unitree"], marker = "extra == 'unitree-dds'" }, @@ -2438,6 +2466,7 @@ requires-dist = [ { name = "lap", marker = "extra == 'perception'", specifier = ">=0.5.12" }, { name = "llvmlite", specifier = ">=0.42.0" }, { name = "lz4", specifier = ">=4.4.5" }, + { name = "matplotlib", marker = "extra == 'grasp'", specifier = ">=3.7.1" }, { name = "matplotlib", marker = "extra == 'manipulation'", specifier = ">=3.7.1" }, { name = "mcap", marker = "extra == 'unitree-dds'", specifier = ">=1.2.0" }, { name = "moondream", marker = "extra == 'perception'" }, @@ -2472,6 +2501,7 @@ requires-dist = [ { name = "pyrealsense2-extended", marker = "sys_platform != 'darwin' and extra == 'manipulation'" }, { name = "python-dotenv" }, { name = "python-multipart", marker = "extra == 'misc'", specifier = ">=0.0.27" }, + { name = "pytorch-ignite", marker = "extra == 'grasp'" }, { name = "pyturbojpeg", specifier = "==1.8.2" }, { name = "pyyaml", marker = "extra == 'manipulation'", specifier = ">=6.0" }, { name = "qpsolvers", extras = ["proxqp"], marker = "extra == 'manipulation'", specifier = ">=4.12.0" }, @@ -2492,6 +2522,7 @@ requires-dist = [ { name = "textual-serve", specifier = ">=1.1.1,<2" }, { name = "timm", marker = "extra == 'misc'", specifier = ">=1.0.15" }, { name = "toolz", specifier = ">=1.1.0" }, + { name = "torch", marker = "extra == 'grasp'" }, { name = "torchreid", marker = "extra == 'misc'", specifier = "==0.2.5" }, { name = "transformers", extras = ["torch"], marker = "extra == 'perception'", specifier = ">=4.53.0,<4.54" }, { name = "trimesh", marker = "extra == 'manipulation'" }, @@ -2501,13 +2532,14 @@ requires-dist = [ { name = "unitree-sdk2py-dimos", marker = "extra == 'unitree-dds'", specifier = ">=1.0.2" }, { name = "unitree-webrtc-connect", marker = "extra == 'unitree'", specifier = ">=2.1.2" }, { name = "uvicorn", marker = "extra == 'web'", specifier = ">=0.34.0" }, + { name = "vgn", marker = "extra == 'grasp'", git = "https://github.com/ethz-asl/vgn.git?rev=d7af0622433f52ae88ebe81533f12b46b33e951a" }, { name = "viser", extras = ["urdf"], marker = "extra == 'manipulation'", specifier = ">=1.0.29" }, { name = "websocket-client", specifier = ">=1.8" }, { name = "xacro", marker = "extra == 'manipulation'" }, { name = "xarm-python-sdk", marker = "extra == 'manipulation'", specifier = ">=1.17.0" }, { name = "xarm-python-sdk", marker = "extra == 'misc'", specifier = ">=1.17.0" }, ] -provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "cpu", "cuda", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] +provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "grasp", "cpu", "cuda", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] [package.metadata.requires-dev] autofix = [{ name = "ruff", specifier = "==0.14.3" }] @@ -2736,6 +2768,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/7b/af3d0da15bed3a8665419bb3a630585756920f4ad67abfdfef26240ebcc0/docstring_to_markdown-0.17-py3-none-any.whl", hash = "sha256:fd7d5094aa83943bf5f9e1a13701866b7c452eac19765380dead666e36d3711c", size = 23479, upload-time = "2025-05-02T15:09:06.676Z" }, ] +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, +] + [[package]] name = "drake" version = "1.45.0" @@ -9190,6 +9231,19 @@ global = [ { name = "platformdirs" }, ] +[[package]] +name = "pytorch-ignite" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/4a/33521d48d45c39eb64081db463d98d28b9962a2baea2858812a5e716ddae/pytorch_ignite-0.5.4.tar.gz", hash = "sha256:1826fd670f44fd6aea20fd7e6297ff30b71b961f4ecb62e7d1040eea106b9071", size = 7520896, upload-time = "2026-03-27T10:42:58.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/8c/1b0a26ab10d3d079533238a50530e565fb341baf8e8c57c641c52437668e/pytorch_ignite-0.5.4-py3-none-any.whl", hash = "sha256:0de99d11a2df2f4a5974e46c2612100d0a7ab0a92e6d8403c44dbae86282e131", size = 357295, upload-time = "2026-03-27T10:42:48.962Z" }, +] + [[package]] name = "pyturbojpeg" version = "1.8.2" @@ -11501,6 +11555,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] +[[package]] +name = "vgn" +version = "0.1.0" +source = { git = "https://github.com/ethz-asl/vgn.git?rev=d7af0622433f52ae88ebe81533f12b46b33e951a#d7af0622433f52ae88ebe81533f12b46b33e951a" } + [[package]] name = "vhacdx" version = "0.0.10" From 017bf360e2a4e28d282f3f03ab27b7e5635b5a4b Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 25 Jun 2026 17:03:06 -0700 Subject: [PATCH 078/110] spec: grasp gen --- CONTEXT.md | 4 + .../vgn-mujoco-grasp-demo/.openspec.yaml | 2 + .../changes/vgn-mujoco-grasp-demo/design.md | 97 +++++++++++++++++++ .../changes/vgn-mujoco-grasp-demo/proposal.md | 26 +++++ .../specs/vgn-mujoco-grasp-demo/spec.md | 60 ++++++++++++ .../changes/vgn-mujoco-grasp-demo/tasks.md | 29 ++++++ .../.openspec.yaml | 2 + .../vgn-tsdf-scene-reconstruction/design.md | 92 ++++++++++++++++++ .../vgn-tsdf-scene-reconstruction/proposal.md | 29 ++++++ .../specs/scene-reconstruction/spec.md | 53 ++++++++++ .../specs/tsdf-grasp-generation/spec.md | 52 ++++++++++ .../vgn-tsdf-scene-reconstruction/tasks.md | 34 +++++++ 12 files changed, 480 insertions(+) create mode 100644 openspec/changes/vgn-mujoco-grasp-demo/.openspec.yaml create mode 100644 openspec/changes/vgn-mujoco-grasp-demo/design.md create mode 100644 openspec/changes/vgn-mujoco-grasp-demo/proposal.md create mode 100644 openspec/changes/vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md create mode 100644 openspec/changes/vgn-mujoco-grasp-demo/tasks.md create mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/.openspec.yaml create mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/design.md create mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/proposal.md create mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md create mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md create mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md index ceef8fa8ed..cad259d7dd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,6 +40,10 @@ _Avoid_: public simulator API, benchmark control plane, module object sharing The hardware-facing subset of simulator state that resembles what a raw robot driver exposes: actuator positions, velocities, efforts, commands, enable state, and errors. _Avoid_: task observation, scene observation, evaluator state +**Whole-body motor surface**: +A hardware control surface that treats a robot as an ordered set of motors with per-motor state and commands, independent of whether the robot is a manipulator, mobile base, or humanoid. +_Avoid_: manipulator-only adapter, end-effector API, task action API + **Damiao-based Robot**: A robot whose joints are actuated by one or more Damiao motors, possibly spread across multiple CAN buses and physical limbs. _Avoid_: Damiao arm when the robot may contain multiple motor groups diff --git a/openspec/changes/vgn-mujoco-grasp-demo/.openspec.yaml b/openspec/changes/vgn-mujoco-grasp-demo/.openspec.yaml new file mode 100644 index 0000000000..de73b342e8 --- /dev/null +++ b/openspec/changes/vgn-mujoco-grasp-demo/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-25 diff --git a/openspec/changes/vgn-mujoco-grasp-demo/design.md b/openspec/changes/vgn-mujoco-grasp-demo/design.md new file mode 100644 index 0000000000..35026d6705 --- /dev/null +++ b/openspec/changes/vgn-mujoco-grasp-demo/design.md @@ -0,0 +1,97 @@ +## Context + +The core VGN reconstruction change defines the TSDF scene product and VGN grasp candidate output. This companion change validates that pipeline in a full simulated setting. The existing xArm simulation blueprint already uses `MujocoSimModule` with `camera_name="wrist_camera"` and `base_frame_id="link7"`, and Rerun visualization is already available through `RerunBridgeModule` and `PointCloud2.to_rerun()`. + +The first demo should prove geometry and frame correctness, not manipulation execution. A simple scene with one object is enough to show whether wrist depth, TSDF reconstruction, VGN inference, and grasp candidate visualization line up. + +## Goals / Non-Goals + +**Goals:** +- Provide an opt-in MuJoCo demo that runs xArm7, wrist depth camera, scene reconstruction, VGN grasp generation, and Rerun visualization together. +- Use a minimal scene: robot, gripper, table, and one simple stable graspable object. +- Visualize scene pointcloud, optional TSDF surface/voxel points, and simplified gripper candidates in world coordinates. +- Make the best/top grasps visually distinct from lower-scoring candidates. +- Keep the demo deterministic enough for repeated local debugging. + +**Non-Goals:** +- Autonomous physical or simulated pick execution. +- Cluttered object piles or benchmark-quality grasp evaluation. +- Viser as the primary visualization path in v1. +- General multi-object scene generation. + +## Decisions + +### Use Rerun first + +Use Rerun for v1 visualization because the xArm simulation stack already includes `RerunBridgeModule`, and `PointCloud2.to_rerun()` already exists. + +Alternatives considered: +- Viser: preferred long-term by the user, but would require more new infrastructure before verifying VGN geometry. +- Open3D-only debug windows: useful for local debugging, but less integrated with the existing DimOS visualization stack. + +### Render grasp candidates as simplified gripper wireframes + +Add `GraspCandidateArray.to_rerun()` or an equivalent visualization helper that logs simplified grippers with `rr.LineStrips3D`. Each candidate pose transforms local gripper geometry into world; jaw width controls finger separation; score controls color/ranking. + +Use fixed v1 visualization config: + +```python +GraspVisConfig: + max_grasps: int = 50 + top_k_highlight: int = 5 + finger_length_m: float = 0.055 + palm_depth_m: float = 0.035 + finger_thickness_m: float = 0.004 + default_width_m: float = 0.08 + min_score: float = 0.0 + top_color: tuple[int, int, int] = (0, 255, 80) + good_color: tuple[int, int, int] = (255, 220, 0) + low_color: tuple[int, int, int] = (255, 80, 0) +``` + +Alternatives considered: +- Pose arrows only: too thin; does not show jaw width or gripper envelope. +- Mesh grippers: prettier but more implementation work than needed for validation. + +### Keep the scene simple + +Use xArm7 with gripper, table, and one simple object such as a cube or mug-like cylinder with a distinct material. Avoid clutter for v1. + +Alternatives considered: +- Multiple cluttered objects: better stress test, but makes first failure diagnosis harder. +- No object, synthetic TSDF only: easier to run, but does not validate MuJoCo camera and scene visualization together. + +### Wrist camera uses existing MuJoCo camera configuration + +Use the existing wrist camera concept attached to `link7` through `MujocoSimModule` configuration. The demo should ensure depth and camera-info streams are enabled and that TF makes the wrist camera frame resolvable to `world`. + +Alternatives considered: +- Static external camera: simpler view, but does not validate the wrist-camera use case needed for real manipulation. + +### Demo is opt-in and separate from existing xArm perception sim + +Add a new demo blueprint/script rather than changing the default xArm perception simulation behavior. + +Alternatives considered: +- Modify `xarm_perception_sim` directly: simpler to find, but risks changing existing workflows and tests. + +## Risks / Trade-offs + +- MuJoCo asset/keyframe changes can break derived scene paths → keep demo asset additions minimal and verify the selected scene path exists. +- Wrist camera frame or depth intrinsics may not match reconstruction assumptions → add explicit Rerun visualization of pointcloud/TSDF/grasp frames and fail clearly when TF is missing. +- VGN may produce no grasps on a too-simple or badly scaled object → choose object size compatible with parallel-jaw gripper and show reconstruction status/model status. +- Rerun line-strip APIs may differ by version → keep visualization helper small and covered by a smoke test or import test. +- This change depends on the core reconstruction/VGN change → implement after or alongside `vgn-tsdf-scene-reconstruction` and avoid duplicating core types. + +## Migration Plan + +1. Add demo assets and visualization helpers behind opt-in demo names. +2. Wire a new demo blueprint/script without changing existing simulation blueprints. +3. Validate visually in Rerun and with smoke tests. +4. Rollback is removing the opt-in demo and helper wiring. + +## Open Questions + +- Exact object shape for the first committed scene: cube versus cylinder/mug-like object. +- Whether the demo should include scripted arm scan poses in v1 or rely on static wrist camera placement first. +- Whether TSDF surface visualization should be produced by the reconstruction module or a demo-only helper. diff --git a/openspec/changes/vgn-mujoco-grasp-demo/proposal.md b/openspec/changes/vgn-mujoco-grasp-demo/proposal.md new file mode 100644 index 0000000000..65b1f6c048 --- /dev/null +++ b/openspec/changes/vgn-mujoco-grasp-demo/proposal.md @@ -0,0 +1,26 @@ +## Why + +VGN grasp integration is hard to validate from unit tests alone because correctness depends on camera geometry, reconstruction frames, object placement, and visualization. A MuJoCo end-to-end demo gives a repeatable scene where generated grasps can be inspected against the robot, table, object, pointcloud, and TSDF-derived scene output. + +## What Changes + +- Add a simulation demo around xArm7 with a wrist depth camera, table, and one simple graspable object. +- Compose MuJoCo depth output, scene reconstruction, VGN grasp generation, and Rerun visualization into an opt-in demo blueprint/script. +- Visualize object/table scene pointcloud, optional TSDF surface/voxel points, and simplified gripper grasp candidates in a fixed Rerun layout. +- Add reusable Rerun visualization for `GraspCandidateArray` using simplified gripper wireframes. +- Keep v1 focused on visual/algorithm verification, not autonomous pick execution. + +## Capabilities + +### New Capabilities +- `vgn-mujoco-grasp-demo`: End-to-end simulated VGN grasp proposal demo with wrist depth camera and Rerun grasp visualization. + +### Modified Capabilities +- None. + +## Impact + +- Affected code areas: xArm simulation blueprints/demos, MuJoCo scene assets, Rerun visualization helpers, and grasp candidate visualization. +- Depends on the core `vgn-tsdf-scene-reconstruction` change for `TSDFGrid`, scene reconstruction, and VGN grasp candidate output. +- Uses existing MuJoCo camera stream support and existing Rerun bridge infrastructure where possible. +- Does not change real robot behavior or existing pick-and-place heuristics. diff --git a/openspec/changes/vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md b/openspec/changes/vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md new file mode 100644 index 0000000000..bb7e117efc --- /dev/null +++ b/openspec/changes/vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Simulated VGN grasp demo +The system SHALL provide an opt-in MuJoCo demo that composes xArm simulation, wrist depth camera output, scene reconstruction, VGN grasp generation, and visualization. + +#### Scenario: Demo composition starts +- **WHEN** the demo blueprint or script is launched with required optional dependencies available +- **THEN** it starts the simulated xArm scene, depth camera streams, scene reconstruction, VGN grasp generation, and Rerun visualization path + +#### Scenario: Existing simulation remains unchanged +- **WHEN** existing xArm simulation blueprints are launched +- **THEN** their behavior is not changed by the VGN demo unless the new demo is selected explicitly + +### Requirement: Minimal graspable scene +The demo SHALL include a repeatable simulated scene with xArm7, gripper, table, and one simple stable graspable object. + +#### Scenario: Scene contains object and support +- **WHEN** the demo scene loads +- **THEN** the robot, gripper, table, and graspable object are present in consistent world-frame positions + +#### Scenario: Object suitable for first validation +- **WHEN** VGN reconstruction and inference run against the demo object +- **THEN** the object scale and placement are compatible with parallel-jaw grasp proposal visualization + +### Requirement: Wrist depth camera validation +The demo SHALL use a wrist-mounted simulated depth camera attached through the existing xArm/MuJoCo camera mechanism. + +#### Scenario: Depth streams available +- **WHEN** the demo starts +- **THEN** depth image and depth camera info streams are available for scene reconstruction + +#### Scenario: Camera transform resolvable +- **WHEN** the reconstruction module receives a depth image from the wrist camera +- **THEN** the camera frame can be resolved to `world` through TF or the frame failure is reported clearly + +### Requirement: Rerun scene visualization +The demo SHALL visualize enough scene context in Rerun to judge whether generated grasps align with the object. + +#### Scenario: Scene products visible +- **WHEN** reconstruction products are available +- **THEN** Rerun shows the object/table scene pointcloud and a TSDF-derived surface or voxel visualization in world coordinates + +#### Scenario: Fixed Rerun entity layout +- **WHEN** the demo logs visualization data +- **THEN** it uses stable entity paths under `world/`, including `world/scene_pointcloud`, `world/tsdf_surface`, and `world/grasp_candidates` + +### Requirement: Simplified gripper candidate visualization +The demo SHALL visualize grasp candidates as simplified gripper wireframes rather than pose arrows only. + +#### Scenario: Candidate wireframes rendered +- **WHEN** a `GraspCandidateArray` is available +- **THEN** Rerun displays up to the configured maximum number of candidates as line-strip gripper wireframes transformed by each candidate pose + +#### Scenario: Width and score visible +- **WHEN** candidates include jaw width and score +- **THEN** jaw width controls finger separation and score controls ordering or color so top candidates are visually distinct + +#### Scenario: No candidates +- **WHEN** VGN returns no candidates +- **THEN** the demo reports the no-candidate outcome clearly instead of showing stale grippers diff --git a/openspec/changes/vgn-mujoco-grasp-demo/tasks.md b/openspec/changes/vgn-mujoco-grasp-demo/tasks.md new file mode 100644 index 0000000000..a2d033823a --- /dev/null +++ b/openspec/changes/vgn-mujoco-grasp-demo/tasks.md @@ -0,0 +1,29 @@ +## 1. Demo scene and simulation wiring + +- [ ] 1.1 Identify the existing xArm MuJoCo scene asset and wrist camera configuration used by `xarm_perception_sim`. +- [ ] 1.2 Add or select a minimal demo scene with xArm7, gripper, table, and one stable graspable object. +- [ ] 1.3 Ensure the simulated wrist camera publishes depth image and depth camera info streams for reconstruction. +- [ ] 1.4 Verify the wrist camera frame can be resolved to `world` through TF during the demo. + +## 2. Demo blueprint/script + +- [ ] 2.1 Create an opt-in VGN grasp demo blueprint or `demo_*` script that does not modify existing xArm simulation defaults. +- [ ] 2.2 Compose MuJoCo simulation, scene reconstruction, VGN grasp generation, and Rerun visualization modules. +- [ ] 2.3 Configure reconstruction workspace and output rate for the demo scene. +- [ ] 2.4 Add a simple demo flow that resets reconstruction, allows/causes depth integration, triggers grasp generation, and keeps visualization live. + +## 3. Grasp visualization + +- [ ] 3.1 Implement `GraspVisConfig` with the fixed v1 parameters from the design. +- [ ] 3.2 Add `GraspCandidateArray` Rerun visualization using simplified gripper `LineStrips3D` wireframes. +- [ ] 3.3 Map candidate jaw width to finger separation and score/rank to color or highlight style. +- [ ] 3.4 Clear or update visualization explicitly when no candidates are available to avoid stale grippers. +- [ ] 3.5 Add optional TSDF surface or voxel-point visualization under a stable Rerun entity path. + +## 4. Validation + +- [ ] 4.1 Add a lightweight smoke test for demo imports and blueprint/script construction. +- [ ] 4.2 Add a visualization helper test for gripper line geometry shape/count using synthetic grasp candidates. +- [ ] 4.3 Run the demo locally and confirm Rerun shows scene pointcloud, TSDF-derived surface/voxels, and grasp wireframes in world alignment. +- [ ] 4.4 Document the demo command and expected visual acceptance criteria. +- [ ] 4.5 Run `openspec status --change "vgn-mujoco-grasp-demo"` and verify artifacts remain apply-ready. diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/.openspec.yaml b/openspec/changes/vgn-tsdf-scene-reconstruction/.openspec.yaml new file mode 100644 index 0000000000..de73b342e8 --- /dev/null +++ b/openspec/changes/vgn-tsdf-scene-reconstruction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-25 diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/design.md b/openspec/changes/vgn-tsdf-scene-reconstruction/design.md new file mode 100644 index 0000000000..335b2d0658 --- /dev/null +++ b/openspec/changes/vgn-tsdf-scene-reconstruction/design.md @@ -0,0 +1,92 @@ +## Context + +DimOS already has depth-capable camera streams (`depth_image`, `depth_camera_info`, TF, and optional `pointcloud`) and a grasp-generation orchestration seam (`GraspingModule` + `GraspGenSpec`). What is missing is a native learned grasp backend and the scene reconstruction representation required by VGN. + +Official VGN reconstructs a TSDF from depth images, camera intrinsics, and camera poses, then runs inference on a grid shaped like `(1, 40, 40, 40)`. It does not build its canonical input from a pointcloud alone. DimOS should therefore model TSDF as a reusable scene product produced beside pointclouds, not as a private VGN adapter detail. + +## Goals / Non-Goals + +**Goals:** +- Introduce a reusable scene reconstruction module that consumes depth observations and publishes pointcloud, TSDF, and status outputs. +- Introduce a `TSDFGrid` message aligned with VGN/Open3D grid semantics. +- Introduce TSDF-native grasp generation with VGN as the first backend. +- Preserve existing pointcloud-oriented `GraspGenSpec` and existing heuristic pick behavior. +- Publish grasp outputs as scored gripper candidates in `world` frame, with PoseArray compatibility. +- Keep high-rate/large sensor and reconstruction payloads on streams; use RPC for control and lifecycle. + +**Non-Goals:** +- Executing robot picks or closing the gripper using VGN output. +- Supporting cluttered bin-picking quality in the first pass. +- Making Viser the primary visualization path in this change. +- Replacing `ObjectSceneRegistrationModule` or semantic object registration. +- Creating a pointcloud-only approximation as the primary VGN path. + +## Decisions + +### Scene reconstruction is generic, not VGN-private + +Create a `SceneReconstructionModule` that consumes depth streams and TF and publishes reconstructed scene products. VGN consumes the TSDF product. + +Alternatives considered: +- Put TSDF integration inside `VGNGraspGenModule`: simpler initially, but hides a reusable scene representation and couples reconstruction lifecycle to one model. +- Build TSDF from `PointCloud2`: easier to wire to existing pointcloud APIs, but loses camera-ray/free-space information needed for faithful TSDF integration. + +### TSDFGrid uses min-corner grid semantics + +`TSDFGrid` carries `distances` shaped `(1, X, Y, Z)`, `voxel_size`, `truncation_distance`, `origin`, `size`, `resolution`, optional `weights`, `frame_id`, and `ts`. Voxel `[0,0,0]` is located at the grid origin/min corner. Workspace RPCs can accept a user-friendly center and convert internally. + +Alternatives considered: +- Center-origin grids: friendlier for humans but adds conversion friction and ambiguity versus VGN/Open3D output. +- VGN-specific wrapper object: less reusable and harder to stream across DimOS modules. + +### Reconstruction continuously ingests but publishes at a fixed output rate + +The reconstruction module may ingest at camera rate or a configured throttle, but publishes `pointcloud`, `tsdf`, and `status` at `reconstruction_fps` such as 2 Hz. RPCs control lifecycle: reset, pause/resume integration, set workspace, snapshot, and status. + +Alternatives considered: +- One-shot RPC carrying depth/camera/TF payloads: easier to reason about for one request, but sends large pickled payloads through RPC and fights DimOS stream architecture. +- Fully manual start/stop-only reconstruction: faithful to scan workflows, but less useful as a general scene product producer. + +### TSDF grasp generation gets a new spec + +Add `TSDFGraspGenSpec.generate_grasps_from_tsdf(tsdf: TSDFGrid) -> GraspCandidateArray | None`. Keep the existing pointcloud `GraspGenSpec` unchanged for pointcloud-based backends. + +Alternatives considered: +- Change `GraspGenSpec` to accept TSDF: would break the existing seam and mix two different input contracts. +- Add optional pointcloud to TSDF generation spec: VGN does not require it for inference, so it should remain a sibling visualization/collision/debug output. + +### Grasp candidates are richer than PoseArray + +Introduce `GraspCandidate` and `GraspCandidateArray` with pose, jaw width, score, and optional id. Publish this as primary output and provide `to_pose_array()` compatibility. + +Alternatives considered: +- Only publish `PoseArray`: insufficient for VGN score, gripper width, visualization, ranking, and later execution. + +### VGN outputs world-frame candidates in v1 + +The VGN module transforms voxel/grid-frame grasps through `TSDFGrid.origin` and TF into `world`, then publishes `GraspCandidateArray.header.frame_id = "world"`. If the transform is unavailable, it returns no result and logs/raises a clear error. + +Alternatives considered: +- Configurable target frame: more flexible, but unnecessary for the first integration and creates more validation combinations. + +## Risks / Trade-offs + +- VGN import triggers ROS visualization side effects in non-ROS environments → lazy-import VGN inside the backend and isolate or shim visualization imports rather than importing `vgn.detection` at module import time. +- TSDF coordinate conventions can silently produce shifted grasps → require unit tests for voxel-to-world conversion and frame id/origin semantics. +- Reconstruction payloads are large → stream them and avoid RPC transport for TSDF/depth arrays. +- VGN model artifacts may not be present → fail with a clear backend status and installation/model-path message. +- Depth/camera/TF synchronization can be brittle → align by timestamps where available and expose reconstruction status with accepted/dropped frame counts. +- `CONTEXT.md` currently has merge conflict markers → do not update glossary/docs there until conflicts are resolved. + +## Migration Plan + +1. Add new message/spec/module types without changing existing grasp or pick paths. +2. Wire VGN into new opt-in blueprints/demos only. +3. Keep `GraspingModule` pointcloud flow and `PickAndPlaceModule` heuristic flow untouched. +4. Rollback is removing the opt-in modules/blueprints while leaving existing behavior unchanged. + +## Open Questions + +- Exact location/name for the reconstruction package: `dimos/perception/reconstruction` versus `dimos/navigation` or another namespace. +- Whether `TSDFGrid.weights` should be required immediately or optional until a downstream consumer needs confidence/observation counts. +- Exact model artifact discovery convention for VGN weights. diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/proposal.md b/openspec/changes/vgn-tsdf-scene-reconstruction/proposal.md new file mode 100644 index 0000000000..81d993d554 --- /dev/null +++ b/openspec/changes/vgn-tsdf-scene-reconstruction/proposal.md @@ -0,0 +1,29 @@ +## Why + +DimOS has an intended grasp-generation seam, but no working native learned grasp backend. VGN is now installable behind the `grasp` extra, and integrating it cleanly requires a depth-based scene reconstruction path because VGN consumes TSDF grids reconstructed from depth observations, not raw pointcloud-only inputs. + +## What Changes + +- Add a generic scene reconstruction capability that consumes depth images, depth camera calibration, and TF to maintain a reconstructed scene. +- Add a `TSDFGrid` message type aligned with Open3D/VGN grid semantics. +- Publish reconstructed scene products at a fixed reconstruction output rate, including `PointCloud2`, `TSDFGrid`, and reconstruction status. +- Add TSDF-native grasp generation contracts and a VGN-backed module that consumes latest TSDF data. +- Add grasp candidate message types carrying pose, jaw width, and score, with PoseArray compatibility for existing consumers. +- Output VGN grasp candidates in `world` frame for simple downstream use. +- Keep high-rate sensor data on streams; use RPC for reconstruction lifecycle/control only. + +## Capabilities + +### New Capabilities +- `scene-reconstruction`: Generic depth-based scene reconstruction that produces pointcloud and TSDF scene products from depth observations. +- `tsdf-grasp-generation`: TSDF-native grasp generation that converts reconstructed TSDF grids into scored world-frame grasp candidates. + +### Modified Capabilities +- None. + +## Impact + +- Affected code areas: `dimos/msgs`, `dimos/perception` or reconstruction modules, `dimos/manipulation/grasping`, and xArm/manipulation blueprints that opt into the new modules. +- New runtime path depends on the existing `grasp` optional extra for VGN and existing depth camera streams. +- Existing `GraspGenSpec` and pointcloud-based grasp paths remain available; this change adds a TSDF-native path rather than replacing them. +- Sensor payloads remain stream-based to avoid large pickled RPC requests. diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md b/openspec/changes/vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md new file mode 100644 index 0000000000..80e32ce909 --- /dev/null +++ b/openspec/changes/vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Depth-based scene reconstruction +The system SHALL provide a scene reconstruction capability that consumes depth images, depth camera calibration, and camera pose information to maintain a reconstructed scene. + +#### Scenario: Integrate depth observation +- **WHEN** a depth image, matching depth camera info, and a TF transform for the depth image frame at the image timestamp are available +- **THEN** the reconstruction capability integrates the observation into the current scene reconstruction + +#### Scenario: Missing transform +- **WHEN** a depth image is available but the camera pose cannot be resolved within the configured tolerance +- **THEN** the reconstruction capability does not integrate that image and records the dropped frame in reconstruction status + +### Requirement: Publish scene products +The system SHALL publish reconstructed scene products as streams, including a pointcloud, a TSDF grid, and reconstruction status. + +#### Scenario: Fixed output rate +- **WHEN** reconstruction is active and scene data is available +- **THEN** the system publishes scene products at the configured reconstruction output rate rather than by RPC request + +#### Scenario: Pointcloud and TSDF share source observations +- **WHEN** the system publishes both pointcloud and TSDF products +- **THEN** both products are derived from the same integrated depth observations rather than converting TSDF from an unrelated pointcloud-only source + +### Requirement: Reconstruction lifecycle control +The system SHALL expose RPC controls for scene reconstruction lifecycle and workspace configuration while keeping large scene payloads on streams. + +#### Scenario: Reset scene +- **WHEN** a caller invokes reset scene control +- **THEN** the current integrated scene is cleared and subsequent scene products reflect only observations integrated after the reset + +#### Scenario: Pause and resume integration +- **WHEN** a caller pauses integration +- **THEN** incoming depth observations are not integrated until integration is resumed + +#### Scenario: Set workspace +- **WHEN** a caller sets a workspace frame, center, and size +- **THEN** the reconstruction capability reconstructs subsequent TSDF data in that workspace and converts the center specification into the internal grid-origin representation + +### Requirement: TSDF grid message +The system SHALL represent TSDF output with a streamable `TSDFGrid` data type using VGN/Open3D-aligned grid semantics. + +#### Scenario: VGN-compatible grid shape +- **WHEN** a TSDF grid is published for VGN consumption +- **THEN** its distances array is a float32 array shaped `(1, X, Y, Z)` and includes resolution metadata matching `X`, `Y`, and `Z` + +#### Scenario: Grid origin semantics +- **WHEN** a consumer maps voxel index `[i, j, k]` to metric space +- **THEN** the voxel position is computed from the grid min-corner origin plus `[i, j, k] * voxel_size` in the TSDF grid frame + +#### Scenario: Optional weights +- **WHEN** integration weights or observation counts are available +- **THEN** the TSDF grid may include weights aligned with the distances grid; otherwise weights are absent without invalidating the TSDF grid diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md b/openspec/changes/vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md new file mode 100644 index 0000000000..b4d8518348 --- /dev/null +++ b/openspec/changes/vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: TSDF-native grasp generation +The system SHALL provide a TSDF-native grasp generation capability that accepts a `TSDFGrid` and returns scored grasp candidates. + +#### Scenario: Generate grasps from TSDF +- **WHEN** a valid TSDF grid is provided to the TSDF grasp generator +- **THEN** the generator returns a `GraspCandidateArray` or a clear no-result outcome + +#### Scenario: No pointcloud required +- **WHEN** VGN-backed grasp generation runs from a TSDF grid +- **THEN** it does not require a pointcloud input for inference + +### Requirement: VGN grasp backend +The system SHALL include a VGN-backed TSDF grasp generation module that consumes latest TSDF stream data and can also generate grasps from an explicit TSDF grid through a typed spec. + +#### Scenario: Latest TSDF generation +- **WHEN** the VGN module has received a latest TSDF grid and a caller requests grasp generation from latest TSDF +- **THEN** the module runs VGN inference on that TSDF grid and publishes the resulting grasp candidates + +#### Scenario: Missing model dependency +- **WHEN** VGN or its model weights are unavailable +- **THEN** the module fails with a clear status or error explaining the missing optional dependency or model path instead of failing at process import time + +### Requirement: Grasp candidate output +The system SHALL represent learned grasp output as grasp candidates containing pose, jaw width, score, and optional id. + +#### Scenario: Candidate metadata +- **WHEN** VGN produces a grasp proposal +- **THEN** the output candidate includes the proposed gripper pose, jaw width in meters, quality score, and a stable id when available + +#### Scenario: PoseArray compatibility +- **WHEN** an existing consumer requires a `PoseArray` +- **THEN** the grasp candidate array can be converted to a `PoseArray` without losing pose ordering + +### Requirement: World-frame VGN output +The system SHALL publish VGN grasp candidates in the `world` frame for the first integration. + +#### Scenario: Transform successful +- **WHEN** VGN predicts a grasp in TSDF grid coordinates and the transform to `world` is available +- **THEN** the published candidate pose is transformed to `world` and the output header frame id is `world` + +#### Scenario: Transform unavailable +- **WHEN** the transform from TSDF grid frame to `world` cannot be resolved +- **THEN** the generator does not publish misleading candidates and reports a clear transform failure + +### Requirement: Stream-first data flow +The system SHALL keep TSDF, depth, and other large scene payloads on streams and use RPC only for control or request/response operations that do not carry high-rate sensor payloads. + +#### Scenario: Avoid large RPC payloads +- **WHEN** reconstructing a scene or generating grasps from live camera data +- **THEN** depth images and TSDF grids flow through streams rather than being bundled into a single pickled RPC request diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/tasks.md b/openspec/changes/vgn-tsdf-scene-reconstruction/tasks.md new file mode 100644 index 0000000000..c97c686a18 --- /dev/null +++ b/openspec/changes/vgn-tsdf-scene-reconstruction/tasks.md @@ -0,0 +1,34 @@ +## 1. Message and spec contracts + +- [ ] 1.1 Add `TSDFGrid` message type with frame id, timestamp, distances, voxel size, truncation distance, origin transform, size, resolution, and optional weights. +- [ ] 1.2 Add `ReconstructionStatus` message type with active/paused state, workspace metadata, accepted frame count, dropped frame count, latest integration timestamp, and latest error/status text. +- [ ] 1.3 Add `GraspCandidate` and `GraspCandidateArray` message types with pose, jaw width, score, optional id, header, and `to_pose_array()` compatibility. +- [ ] 1.4 Add or update serialization/encode/decode helpers and type annotations for new messages. +- [ ] 1.5 Add unit tests for `TSDFGrid` shape metadata, voxel-to-frame coordinate semantics, and `GraspCandidateArray.to_pose_array()` ordering. + +## 2. Scene reconstruction module + +- [ ] 2.1 Create a generic `SceneReconstructionModule` consuming `depth_image` and `depth_camera_info` streams plus TF lookups. +- [ ] 2.2 Implement workspace configuration using user-facing frame, center, and size while storing TSDF grid origin as the min-corner frame transform. +- [ ] 2.3 Implement continuous depth integration with configurable ingest throttle and fixed `reconstruction_fps` publication. +- [ ] 2.4 Publish `pointcloud`, `tsdf`, and `status` streams derived from the same accepted depth observations. +- [ ] 2.5 Add RPC controls: `reset_scene`, `pause_integration`, `resume_integration`, `set_workspace`, `snapshot_scene`, and `get_reconstruction_status`. +- [ ] 2.6 Track and publish dropped-frame status when camera info or TF is missing or stale. +- [ ] 2.7 Add unit tests for lifecycle controls, publication throttling, missing-transform handling, and workspace origin conversion. + +## 3. TSDF grasp generation module + +- [ ] 3.1 Add `TSDFGraspGenSpec.generate_grasps_from_tsdf(tsdf: TSDFGrid) -> GraspCandidateArray | None`. +- [ ] 3.2 Implement `VGNGraspGenModule` with lazy VGN imports and clear errors for missing optional dependency or model weights. +- [ ] 3.3 Subscribe to latest `TSDFGrid` stream and expose RPC generation from latest TSDF. +- [ ] 3.4 Convert VGN voxel-coordinate predictions into `TSDFGrid` frame poses using voxel size and grid origin semantics. +- [ ] 3.5 Transform grasp candidates into `world` frame and report a clear failure when the transform is unavailable. +- [ ] 3.6 Publish primary `GraspCandidateArray` output and optional PoseArray compatibility output. +- [ ] 3.7 Add unit tests for lazy import failure, VGN output conversion, world-frame transform behavior, and no-pointcloud-required inference path. + +## 4. Wiring and validation + +- [ ] 4.1 Add opt-in blueprint wiring for depth camera → scene reconstruction → VGN grasp generation without changing existing pick-and-place behavior. +- [ ] 4.2 Add smoke tests or lightweight integration tests using synthetic TSDF data and stubbed VGN outputs. +- [ ] 4.3 Run targeted pytest for new messages, reconstruction module, and grasp generation module. +- [ ] 4.4 Run `openspec status --change "vgn-tsdf-scene-reconstruction"` and verify artifacts remain apply-ready. From 210648dca031c6fe09ff3e7fd4e2ea6a0ebc02f5 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 25 Jun 2026 19:25:14 -0700 Subject: [PATCH 079/110] spec: robosuite integration --- CONTEXT.md | 28 +++- .../.openspec.yaml | 2 + .../framework-robosuite-integration/design.md | 143 ++++++++++++++++++ .../proposal.md | 33 ++++ .../benchmark-prelaunch-orchestration/spec.md | 55 +++++++ .../specs/robosuite-runtime-sidecar/spec.md | 47 ++++++ .../specs/runtime-sidecar-protocol/spec.md | 48 ++++++ .../specs/scripted-runtime-demos/spec.md | 41 +++++ .../framework-robosuite-integration/tasks.md | 59 ++++++++ .../drafts/agentic-skill-benchmark-harness.md | 26 ++++ 10 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/framework-robosuite-integration/.openspec.yaml create mode 100644 openspec/changes/framework-robosuite-integration/design.md create mode 100644 openspec/changes/framework-robosuite-integration/proposal.md create mode 100644 openspec/changes/framework-robosuite-integration/specs/benchmark-prelaunch-orchestration/spec.md create mode 100644 openspec/changes/framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md create mode 100644 openspec/changes/framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md create mode 100644 openspec/changes/framework-robosuite-integration/specs/scripted-runtime-demos/spec.md create mode 100644 openspec/changes/framework-robosuite-integration/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md index cad259d7dd..aaa42747a6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,8 +33,8 @@ A depth image interpreted with its camera calibration and the pose of its camera _Avoid_: depth point cloud, raw 3D points **SHM runtime data plane**: -A shared-memory command/state channel between a simulated hardware adapter and a simulator runtime, used when high-rate control must cross process boundaries without RPC. -_Avoid_: public simulator API, benchmark control plane, module object sharing +A local shared-memory command/state channel between a ControlCoordinator-facing hardware adapter and a DimOS simulator client module, used when high-rate motor control must cross local process boundaries without RPC. +_Avoid_: remote sidecar protocol, public simulator API, benchmark control plane, module object sharing **Motor state projection**: The hardware-facing subset of simulator state that resembles what a raw robot driver exposes: actuator positions, velocities, efforts, commands, enable state, and errors. @@ -44,6 +44,30 @@ _Avoid_: task observation, scene observation, evaluator state A hardware control surface that treats a robot as an ordered set of motors with per-motor state and commands, independent of whether the robot is a manipulator, mobile base, or humanoid. _Avoid_: manipulator-only adapter, end-effector API, task action API +**Benchmark episode config**: +A backend-facing declaration of benchmark intent that names the task, robot, runtime constraints, and evaluation setup before any DimOS blueprint is launched. +_Avoid_: hardware config, simulator config, blueprint config + +**Resolved runtime plan**: +The concrete DimOS launch material derived from a benchmark episode config, including hardware components, simulator connection config, observation streams, evaluator setup, and artifact routing. +_Avoid_: benchmark intent, user-authored task config + +**Runtime prelaunch orchestration**: +The phase that starts and coordinates the simulator sidecar environment and the DimOS blueprint environment before a benchmark episode begins. +_Avoid_: config parsing, blueprint launch, single-process startup + +**Remote runtime boundary**: +The network-facing protocol boundary between a DimOS simulator client and a benchmark backend process that may run in another environment or on another machine. +_Avoid_: shared memory boundary, hardware adapter boundary, in-process simulator object + +**Runtime protocol schema**: +The shared, backend-neutral message contract used on the remote runtime boundary to describe episodes, robot motor surfaces, actions, observations, scores, and artifacts. +_Avoid_: backend SDK type, DimOS hardware adapter type, simulator object + +**Runtime protocol package**: +A lightweight installable package in the monorepo that contains only remote runtime protocol schemas, codecs, and compatibility tests so sidecars can depend on it without installing DimOS. +_Avoid_: DimOS submodule, simulator backend package, hardware adapter package + **Damiao-based Robot**: A robot whose joints are actuated by one or more Damiao motors, possibly spread across multiple CAN buses and physical limbs. _Avoid_: Damiao arm when the robot may contain multiple motor groups diff --git a/openspec/changes/framework-robosuite-integration/.openspec.yaml b/openspec/changes/framework-robosuite-integration/.openspec.yaml new file mode 100644 index 0000000000..578bd54974 --- /dev/null +++ b/openspec/changes/framework-robosuite-integration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-26 diff --git a/openspec/changes/framework-robosuite-integration/design.md b/openspec/changes/framework-robosuite-integration/design.md new file mode 100644 index 0000000000..ed8cfcf028 --- /dev/null +++ b/openspec/changes/framework-robosuite-integration/design.md @@ -0,0 +1,143 @@ +## Context + +DimOS currently has strong runtime pieces for modules, blueprints, ControlCoordinator hardware abstraction, whole-body adapters, MCP skills, and run artifacts. It does not yet have a benchmark runtime boundary that can connect those pieces to simulator backends that run in isolated Python environments or on separate machines. + +Robosuite is the first backend because it provides baked manipulation scenes such as `Lift`, `Stack`, and `PickPlace` via `robosuite.make(...)`. A benchmark should reference those baked tasks rather than manually spelling scene geometry, robot placement, or Robosuite action indices in DimOS configs. The integration must also preserve dependency isolation: sidecars cannot require installation of the full `dimos` package because Robosuite, LIBERO-PRO, and BEHAVIOR/OmniGibson may have conflicting dependencies. + +The working architecture separates three boundaries: + +1. Remote runtime boundary: network protocol between DimOS and simulator sidecar. +2. Local motor bridge: local SHM between the DimOS simulator client module and a ControlCoordinator-facing WholeBodyAdapter. +3. Benchmark orchestration boundary: prelaunch runner owns both sidecar and DimOS blueprint lifetimes. + +## Goals / Non-Goals + +**Goals:** + +- Define a backend-neutral runtime protocol shared by DimOS and sidecars. +- Package that protocol as a lightweight monorepo package that does not import DimOS or simulator SDKs. +- Add a prelaunch orchestration flow that starts the sidecar first, asks it to describe the live runtime, derives DimOS launch material, then starts a blueprint. +- Add DimOS-side runtime client components that translate between protocol frames, local SHM motor frames, and DimOS observation streams. +- Add a Robosuite sidecar package that owns `env.reset()` / `env.step()` and maps Robosuite observations/actions to protocol frames. +- Verify the framework with two script-based demos: fake sidecar smoke test and Robosuite Panda Lift plumbing test. + +**Non-Goals:** + +- No new `dimos benchmark` CLI command. +- No LLM/agent task-success benchmark in this change. +- No code-as-policy sandbox. +- No broad benchmark taxonomy or leaderboard. +- No requirement that Robosuite sidecar run in the same environment or on the same machine as DimOS. +- No SHM across the remote sidecar boundary. + +## Decisions + +### Decision: Use first-class monorepo packages for protocol and sidecars + +The shared runtime protocol will live in a separate lightweight package, for example `packages/dimos-runtime-protocol`, with a neutral import path such as `dimos_runtime_protocol`. Backend sidecars will also be first-class packages under `packages/`, each with its own `pyproject.toml`, `src/`, and tests. + +Rationale: sidecars need protocol types without importing the full DimOS package. Separate package projects preserve monorepo development while allowing isolated virtual environments and backend-specific dependency sets. + +Alternatives considered: + +- Put protocol under `dimos/simulation/runtime_protocol`: rejected because sidecars would need to install/import DimOS. +- Use loose scripts plus requirements files: rejected because protocol compatibility, packaging, and test isolation become ad hoc. + +### Decision: Use Pydantic protocol models plus binary-friendly codec + +Protocol messages will be Pydantic models for metadata, envelopes, robot surfaces, actions, states, observations, scores, and errors. Transport payloads may use msgpack or another binary-friendly codec, with Pydantic validating after decode and before encode. + +Large arrays and images must not be represented as nested JSON lists in normal operation. Protocol envelopes may carry small numeric arrays directly for motor state/action, and image/depth data should use binary payloads or references with metadata such as shape, dtype, encoding, and stream name. + +Rationale: Pydantic gives a shared contract and compatibility checks while avoiding simulator-specific SDK objects at the boundary. + +Alternatives considered: + +- Raw untyped dicts: rejected because client and sidecar would drift. +- Protobuf first: deferred because Pydantic is already a core dependency and easier to iterate during v1. + +### Decision: Keep the remote sidecar boundary network-first + +The remote boundary between `SimConnectionModule` and runtime sidecar is a network protocol. The sidecar may run in a separate virtual environment, container, host, or benchmark-managed process. It owns the simulator environment and calls backend-native reset/step APIs. + +Rationale: BEHAVIOR/OmniGibson uses a remote websocket policy pattern where evaluator/simulator and policy server communicate over the network. Robosuite and LIBERO-PRO should not be designed around local shared memory assumptions. + +Alternatives considered: + +- Direct SHM between sidecar and hardware adapter: rejected because it assumes same machine/process environment and confuses remote simulator protocol with local control plumbing. +- In-process simulator import from DimOS: rejected for dependency isolation. + +### Decision: Keep SHM local to DimOS control plumbing + +Local SHM is used only between DimOS runtime client code and a ControlCoordinator-facing `WholeBodyAdapter`. The adapter exposes `MotorState[]` and accepts `MotorCommand[]`; the runtime client translates those local frames to/from remote protocol frames. + +Rationale: ControlCoordinator adapters are synchronous hardware surfaces. A local SHM bridge lets the coordinator interact with simulated motors without making the coordinator depend on network protocol details. + +Alternatives considered: + +- ControlCoordinator directly calls network protocol: rejected because network behavior should not leak into hardware adapter contracts. +- Sidecar creates SHM directly: rejected because remote sidecar may not be local. + +### Decision: Prelaunch orchestrator owns both runtimes + +The benchmark prelaunch/runner starts the sidecar, waits for health/metadata, derives a resolved runtime plan, starts the DimOS blueprint, monitors both processes, collects artifacts, and tears both down. + +Rationale: benchmark episodes need one outer owner for cleanup, failure attribution, artifact collection, and repeatability. `SimConnectionModule` is a sidecar client, not a sidecar process owner. + +Alternatives considered: + +- Let `SimConnectionModule` adopt sidecar lifetime: rejected because lifecycle and artifact ownership would be split across environments. +- Treat sidecar as a preexisting daemon only: deferred as a later deployment option. + +### Decision: Robosuite configs name baked scenes and robot profiles + +User-authored benchmark configs name Robosuite baked tasks such as `env_name: Lift`, `robots: Panda`, controller profile, horizon, control frequency, seed, and desired observation streams. They do not normally enumerate every motor-to-action index. The sidecar constructs the live environment, discovers the robot motor surface and action/observation layout, and reports that description to the resolver. + +Rationale: Robosuite builds scenes by instantiating environment classes through `robosuite.make(...)`; scene assets, objects, robot placement, reward, and success logic are part of the task environment. + +Alternatives considered: + +- Manual `motor_map` in every task config: rejected as too bulky and brittle. Manual mapping may remain an escape hatch for unsupported robots/controllers. + +### Decision: Demos are script-based and plumbing-oriented + +The change will add plain scripts that orchestrate the fake sidecar and Robosuite Panda Lift demos. Scripts may call or build blueprints directly. No `dimos` CLI command is introduced. + +Rationale: the first acceptance target is proving the architecture, not creating a stable public user interface. + +Alternatives considered: + +- Add `dimos benchmark run`: rejected as premature. +- Require an agent/LLM to solve a task: rejected because it tests agent capability rather than runtime plumbing. + +## Risks / Trade-offs + +- Backend dependency conflicts → isolate each sidecar in its own package and environment; keep protocol package dependency-light. +- Robosuite action semantics vary by controller → require sidecar runtime description and profile validation; support manual overrides only as escape hatches. +- Network latency can disturb high-rate control → treat v1 demos as plumbing validation; record requested/actual timing and command latency in artifacts. +- Duplicated package version skew between DimOS and sidecar → include protocol version and compatibility checks in handshake. +- Large observations can overload simple JSON payloads → use binary-friendly transport and references for image/depth frames. +- Fake sidecar may give false confidence → require both fake-sidecar smoke demo and real Robosuite Panda Lift plumbing demo. +- Prelaunch orchestration can leave orphan processes → runner must own process groups, health checks, teardown, and failure artifacts. + +## Migration Plan + +This change is additive. Existing DimOS blueprints, hardware adapters, and CLI commands remain unchanged. + +Implementation order: + +1. Add shared protocol package and compatibility tests. +2. Add fake sidecar and DimOS runtime client plumbing. +3. Add prelaunch/resolved-plan scaffolding and fake sidecar demo script. +4. Add Robosuite sidecar package and Robosuite mapping/profile support. +5. Add Robosuite Panda Lift demo script and artifact checks. +6. Update the existing roadmap draft to reference this change as the first concrete implementation slice. + +Rollback is removing the new packages, demo scripts, and change-specific configs; no existing runtime behavior is replaced. + +## Open Questions + +- Exact transport library choice for v1 websocket/msgpack implementation. +- Exact blueprint shape used by the scripts during demos. +- Whether the Robosuite sidecar should support remote preexisting endpoints in addition to subprocess launch in the first implementation. +- Final artifact directory naming and trace summarization format. diff --git a/openspec/changes/framework-robosuite-integration/proposal.md b/openspec/changes/framework-robosuite-integration/proposal.md new file mode 100644 index 0000000000..c8b8f2defc --- /dev/null +++ b/openspec/changes/framework-robosuite-integration/proposal.md @@ -0,0 +1,33 @@ +## Why + +DimOS can compose robot modules, control hardware through the ControlCoordinator, and expose skills to agents, but it does not yet have a backend-neutral way to run benchmark simulator episodes from isolated environments. Robosuite is the first concrete backend target because it provides baked tabletop manipulation tasks while requiring simulator-specific dependencies and runtime behavior that should not be forced into the main DimOS process. + +## What Changes + +- Add a backend-neutral runtime sidecar framework for benchmark episodes. +- Add a lightweight shared protocol package that can be installed by both DimOS and simulator sidecars without installing the full DimOS package. +- Add a prelaunch orchestration path that starts the simulator sidecar environment, reads live runtime metadata, derives DimOS hardware/module launch material, and then launches a DimOS blueprint. +- Add a local DimOS control bridge where the ControlCoordinator talks to a WholeBodyAdapter through a local SHM motor data plane, while DimOS talks to the sidecar through a network protocol. +- Add a Robosuite sidecar integration for baked Robosuite tasks such as Panda Lift, deriving the motor surface and observation streams from the live Robosuite environment. +- Add two script-based E2E demos: a fake sidecar smoke demo and a Robosuite Panda Lift plumbing demo. +- Do not add a `dimos` CLI command in this change. +- Do not require an LLM/agent task-success demo in this change. + +## Capabilities + +### New Capabilities +- `runtime-sidecar-protocol`: Shared remote runtime protocol schemas, codecs, compatibility rules, and network session semantics for DimOS-to-sidecar communication. +- `benchmark-prelaunch-orchestration`: Benchmark episode config resolution, sidecar startup, live metadata discovery, resolved runtime plan generation, DimOS blueprint launch, monitoring, teardown, and artifact ownership. +- `robosuite-runtime-sidecar`: Robosuite sidecar package and mapping layer for baked Robosuite tasks, whole-body motor surfaces, network step/reset/score operations, and observation export. +- `scripted-runtime-demos`: Script-based E2E demos that verify the fake sidecar and Robosuite Panda Lift plumbing without packaging a new DimOS CLI command or requiring LLM task success. + +### Modified Capabilities +- None. + +## Impact + +- Adds monorepo package projects under `packages/` for the shared runtime protocol and backend sidecars. +- Adds DimOS-side runtime client, prelaunch, resolved-plan, local SHM bridge, and WholeBodyAdapter integration code. +- Adds optional Robosuite-side dependencies isolated to the Robosuite sidecar package, not the main DimOS package. +- Adds plain demo scripts and benchmark configs for fake sidecar and Robosuite Panda Lift plumbing demos. +- Produces benchmark artifacts such as episode config, sidecar metadata, resolved runtime plan, protocol trace summary, motor trace, observations, score output, and logs. diff --git a/openspec/changes/framework-robosuite-integration/specs/benchmark-prelaunch-orchestration/spec.md b/openspec/changes/framework-robosuite-integration/specs/benchmark-prelaunch-orchestration/spec.md new file mode 100644 index 0000000000..08d824ddb4 --- /dev/null +++ b/openspec/changes/framework-robosuite-integration/specs/benchmark-prelaunch-orchestration/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Benchmark episode config +The system SHALL support a benchmark episode config that declares benchmark intent, including backend selection, task identity, robot profile, control constraints, observation needs, evaluator expectations, and artifact destination before a DimOS blueprint is launched. + +#### Scenario: Robosuite task is declared as benchmark intent +- **WHEN** an episode config names backend `robosuite`, env name `Lift`, robot `Panda`, controller profile, control frequency, horizon, and seed +- **THEN** the config is treated as portable benchmark intent rather than as a precomputed DimOS hardware component + +### Requirement: Runtime prelaunch orchestration +The system SHALL provide a prelaunch orchestrator that starts the simulator sidecar first, obtains live sidecar metadata, derives concrete DimOS launch material, and then launches the DimOS blueprint. + +#### Scenario: Sidecar describes runtime before blueprint launch +- **WHEN** prelaunch starts a sidecar for an episode +- **THEN** it waits for sidecar health and runtime description before creating the resolved runtime plan used to launch DimOS + +#### Scenario: Sidecar health fails +- **WHEN** the sidecar does not become healthy within the configured timeout +- **THEN** prelaunch fails without launching the DimOS blueprint and writes a failure artifact + +### Requirement: Resolved runtime plan +The system SHALL derive a resolved runtime plan from live sidecar metadata and the episode config, including hardware components, simulator connection config, observation stream config, evaluator config, and artifact routing. + +#### Scenario: Hardware component is derived from sidecar metadata +- **WHEN** the sidecar reports an ordered whole-body motor surface for `panda` +- **THEN** the resolved runtime plan includes a matching `HardwareComponent` projection for the ControlCoordinator + +#### Scenario: Mismatched robot profile fails +- **WHEN** the episode config requests a robot profile that is incompatible with the sidecar-described motor surface +- **THEN** prelaunch fails before starting the DimOS blueprint and records the mismatch + +### Requirement: Runner owns both runtime lifetimes +The benchmark runner SHALL remain the parent owner of both the simulator sidecar process/environment and the DimOS blueprint process/environment for the duration of a demo or benchmark episode. + +#### Scenario: DimOS blueprint exits early +- **WHEN** the DimOS blueprint process exits before the episode completes +- **THEN** the runner tears down the sidecar and records failure attribution and logs from both runtimes + +#### Scenario: Sidecar exits early +- **WHEN** the sidecar exits before the episode completes +- **THEN** the runner tears down the DimOS blueprint and records sidecar failure details + +### Requirement: Local SHM is not a remote sidecar protocol +The system SHALL restrict SHM usage to local DimOS motor control plumbing between the runtime client module and ControlCoordinator-facing WholeBodyAdapter. + +#### Scenario: Sidecar runs remotely +- **WHEN** the sidecar endpoint is configured for another host +- **THEN** DimOS communicates with it through the runtime network protocol and does not require remote SHM access + +### Requirement: Plain script entrypoints +The system SHALL provide plain script entrypoints for v1 demos and MUST NOT require a new `dimos` CLI command for this change. + +#### Scenario: Demo is launched from script +- **WHEN** a developer runs the fake sidecar or Robosuite demo script +- **THEN** the script performs prelaunch orchestration and calls or builds the relevant DimOS blueprint directly diff --git a/openspec/changes/framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md b/openspec/changes/framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md new file mode 100644 index 0000000000..23dd9ddfb9 --- /dev/null +++ b/openspec/changes/framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Robosuite sidecar package +The system SHALL provide a first-class Robosuite sidecar package in the monorepo that depends on the runtime protocol package and Robosuite-specific dependencies without depending on the main DimOS package. + +#### Scenario: Robosuite sidecar installs in isolated environment +- **WHEN** a developer installs the Robosuite sidecar package in a Robosuite-compatible environment +- **THEN** the sidecar can start and import runtime protocol models without installing the main DimOS package + +### Requirement: Baked Robosuite task instantiation +The Robosuite sidecar SHALL instantiate baked Robosuite tasks from episode config fields such as env name, robot name, controller profile, control frequency, horizon, renderer options, camera options, and seed. + +#### Scenario: Panda Lift task starts +- **WHEN** the episode config requests `env_name: Lift` and `robots: Panda` +- **THEN** the sidecar creates the corresponding Robosuite environment and exposes its runtime description + +### Requirement: Runtime-derived motor surface +The Robosuite sidecar SHALL derive robot motor surface metadata from the live Robosuite environment and controller setup rather than requiring every benchmark config to manually enumerate Robosuite action indices. + +#### Scenario: Panda motor order is described +- **WHEN** the sidecar creates a Panda Lift environment +- **THEN** it reports a stable ordered motor surface suitable for DimOS whole-body motor control + +#### Scenario: Unsupported controller profile fails +- **WHEN** the selected Robosuite controller profile cannot be mapped to a supported DimOS motor command mode +- **THEN** the sidecar rejects the episode setup with a protocol error that identifies the unsupported profile + +### Requirement: Robosuite step ownership +The Robosuite sidecar SHALL own backend-native `env.reset()` and `env.step(action)` calls and SHALL translate between runtime protocol action/state frames and Robosuite action/observation structures. + +#### Scenario: Motor position step advances Robosuite +- **WHEN** DimOS sends a motor position action frame for the described Panda motor surface +- **THEN** the sidecar maps it to the Robosuite action vector, steps the environment, and returns motor state, reward, done, success if available, and observation metadata + +### Requirement: Observation export +The Robosuite sidecar SHALL expose configured Robosuite camera and state observations through runtime protocol observation frames that DimOS can publish as observation streams. + +#### Scenario: Agentview camera is available +- **WHEN** the episode config enables the `agentview` camera +- **THEN** step responses include observation frames that allow DimOS to publish the camera output as a stream + +### Requirement: Score and artifact export +The Robosuite sidecar SHALL provide score and artifact outputs for each episode, including reward/done/success metadata, backend timing, and sidecar logs or trace summaries. + +#### Scenario: Score is collected after demo +- **WHEN** the demo completes or times out +- **THEN** the runner can request score output from the sidecar and write it with the episode artifacts diff --git a/openspec/changes/framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md b/openspec/changes/framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md new file mode 100644 index 0000000000..6b8b6cfb14 --- /dev/null +++ b/openspec/changes/framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Shared runtime protocol package +The system SHALL provide a lightweight installable runtime protocol package that can be used by DimOS and simulator sidecars without installing the main DimOS package or any simulator backend SDK. + +#### Scenario: Sidecar installs protocol without DimOS +- **WHEN** a Robosuite sidecar environment installs the runtime protocol package +- **THEN** it can import the protocol models and codecs without importing `dimos`, Robosuite-incompatible DimOS dependencies, or any DimOS hardware adapter modules + +#### Scenario: DimOS imports the same protocol package +- **WHEN** the DimOS runtime client imports protocol models +- **THEN** it uses the same package and protocol version as the sidecar compatibility handshake + +### Requirement: Protocol model validation +The protocol package SHALL define Pydantic models for runtime description, episode reset, step requests, step responses, robot motor surfaces, motor action frames, motor state frames, observation frames, scores, artifacts, and errors. + +#### Scenario: Invalid step request is rejected +- **WHEN** a step request omits required episode identity, tick identity, or action payload fields +- **THEN** protocol validation rejects the message before backend-specific step logic runs + +#### Scenario: Runtime description reports motor surface +- **WHEN** a sidecar describes a robot runtime +- **THEN** the response includes robot id, surface type, ordered motors, supported command modes, and available state fields + +### Requirement: Protocol compatibility handshake +The runtime protocol SHALL include protocol version and capability metadata in the sidecar handshake so DimOS can fail fast on incompatible protocol versions or unsupported capabilities. + +#### Scenario: Compatible sidecar connects +- **WHEN** DimOS connects to a sidecar using a compatible protocol version +- **THEN** the runtime client accepts the sidecar description and records the protocol version in artifacts + +#### Scenario: Incompatible sidecar connects +- **WHEN** DimOS connects to a sidecar using an incompatible protocol version +- **THEN** prelaunch fails before launching the DimOS blueprint and records the incompatibility reason + +### Requirement: Binary-friendly observation transport +The runtime protocol SHALL support image, depth, segmentation, and object/state observations without requiring large image tensors to be encoded as nested JSON lists. + +#### Scenario: Image observation uses reference or binary payload +- **WHEN** a sidecar returns an RGB image observation +- **THEN** the observation frame includes stream name, kind, encoding, shape, dtype, and either a binary payload reference or a supported binary payload representation + +### Requirement: Backend-neutral protocol types +Runtime protocol models MUST NOT expose Robosuite, LIBERO-PRO, OmniGibson, DimOS hardware adapter, or simulator object types in public fields. + +#### Scenario: Robosuite observation is translated +- **WHEN** Robosuite produces an `OrderedDict` observation +- **THEN** the Robosuite sidecar translates it into runtime protocol observation and motor state frames before sending it to DimOS diff --git a/openspec/changes/framework-robosuite-integration/specs/scripted-runtime-demos/spec.md b/openspec/changes/framework-robosuite-integration/specs/scripted-runtime-demos/spec.md new file mode 100644 index 0000000000..bed2f1b27a --- /dev/null +++ b/openspec/changes/framework-robosuite-integration/specs/scripted-runtime-demos/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Fake sidecar smoke demo +The system SHALL include a script-based fake sidecar smoke demo that validates protocol handshake, prelaunch orchestration, resolved runtime plan generation, local motor bridge behavior, ControlCoordinator integration, and artifact output without requiring Robosuite. + +#### Scenario: Fake demo completes in normal DimOS environment +- **WHEN** a developer runs the fake sidecar demo script in the normal DimOS development environment +- **THEN** the demo completes a configured number of ticks and writes episode config, resolved plan, protocol trace summary, motor trace, score output, and logs + +#### Scenario: Fake demo exercises motor command/state flow +- **WHEN** the fake demo runs a scripted motor command sequence +- **THEN** commands flow from the ControlCoordinator-facing surface through the local bridge and protocol client, and motor states flow back into the DimOS side + +### Requirement: Robosuite Panda Lift plumbing demo +The system SHALL include a script-based Robosuite Panda Lift plumbing demo that validates the real Robosuite sidecar, runtime description, derived motor mapping, network protocol, local motor bridge, observation stream export, score collection, and artifact output. + +#### Scenario: Robosuite demo starts sidecar and DimOS blueprint +- **WHEN** a developer runs the Robosuite Panda Lift demo script with a compatible Robosuite sidecar environment +- **THEN** the script starts the sidecar, derives the resolved runtime plan from live sidecar metadata, starts the DimOS blueprint, runs the scripted sequence, collects artifacts, and tears down both runtimes + +#### Scenario: Robosuite joint state changes from scripted command +- **WHEN** the Robosuite demo sends a scripted motor position command sequence +- **THEN** the returned Robosuite-derived motor state changes consistently with the command sequence and is recorded in the motor trace + +#### Scenario: Robosuite observation stream is exported +- **WHEN** the Robosuite demo enables a camera observation stream +- **THEN** DimOS-side artifacts or logs show that at least one observation frame was received from the sidecar and published or recorded by the runtime client + +### Requirement: No agent success requirement +The scripted demos SHALL verify runtime plumbing and MUST NOT require an LLM, MCP skill policy, or successful task completion by an agent. + +#### Scenario: Robosuite task is not solved +- **WHEN** the scripted Robosuite demo does not lift the object successfully +- **THEN** the demo can still pass if protocol, motor flow, observation flow, score collection, and teardown satisfy the demo acceptance checks + +### Requirement: No DimOS CLI integration +The scripted demos SHALL be launched through plain scripts and MUST NOT require a new `dimos benchmark` command. + +#### Scenario: Developer runs demo script directly +- **WHEN** a developer invokes the demo script with a config path +- **THEN** the script performs orchestration directly rather than delegating to a new DimOS CLI subcommand diff --git a/openspec/changes/framework-robosuite-integration/tasks.md b/openspec/changes/framework-robosuite-integration/tasks.md new file mode 100644 index 0000000000..871fa902e6 --- /dev/null +++ b/openspec/changes/framework-robosuite-integration/tasks.md @@ -0,0 +1,59 @@ +## 1. Package and dependency boundaries + +- [ ] 1.1 Create `packages/dimos-runtime-protocol` as a lightweight installable package with its own `pyproject.toml`, `src/`, and tests. +- [ ] 1.2 Create first-class sidecar package skeletons for `packages/dimos-robosuite-sidecar` and the fake/demo sidecar support without depending on the main `dimos` package. +- [ ] 1.3 Add optional dependency wiring or developer documentation so DimOS can install the runtime protocol package while Robosuite sidecar environments can install only the protocol plus sidecar package. +- [ ] 1.4 Add import-boundary tests that prove `dimos_runtime_protocol` imports without importing `dimos`, Robosuite, LIBERO-PRO, or OmniGibson. + +## 2. Runtime protocol + +- [ ] 2.1 Define Pydantic protocol models for handshake, runtime description, episode reset, step request, step response, robot motor surfaces, motor action frames, motor state frames, observation frames, score output, artifact output, and protocol errors. +- [ ] 2.2 Add protocol version and capability compatibility checks used during sidecar handshake. +- [ ] 2.3 Implement binary-friendly codec helpers for protocol envelopes and small numeric arrays, with a path for image/depth payload references or binary payloads. +- [ ] 2.4 Add protocol validation tests for malformed step requests, incompatible versions, robot surface descriptions, and observation frame metadata. + +## 3. DimOS-side runtime client and local motor bridge + +- [ ] 3.1 Implement a DimOS-side runtime client that connects to a sidecar endpoint, performs health/handshake, resets an episode, exchanges step frames, and retrieves score/artifact metadata. +- [ ] 3.2 Implement the local SHM motor bridge between the runtime client module and a WholeBodyAdapter-facing local motor data plane. +- [ ] 3.3 Implement or register a WholeBodyAdapter that reads `MotorState[]` and writes `MotorCommand[]` through the local SHM bridge for benchmark runtime use. +- [ ] 3.4 Add observation publishing hooks that translate protocol observation frames into DimOS streams or demo artifacts without exposing simulator SDK objects. +- [ ] 3.5 Add unit tests for local motor command/state round-trips and runtime client error handling. + +## 4. Prelaunch orchestration and resolved plans + +- [ ] 4.1 Define `BenchmarkEpisodeConfig` for backend intent, including backend, task, robot profile, control timing, observation streams, evaluator expectations, and artifact destination. +- [ ] 4.2 Define `ResolvedRuntimePlan` containing derived hardware components, runtime client config, observation stream config, evaluator config, artifact routing, and sidecar metadata. +- [ ] 4.3 Implement prelaunch orchestration that starts the sidecar, waits for health, retrieves runtime description, validates robot profiles, builds the resolved runtime plan, launches the DimOS blueprint directly, monitors both runtimes, and tears both down. +- [ ] 4.4 Add failure handling for sidecar health timeout, protocol incompatibility, robot profile mismatch, early DimOS exit, early sidecar exit, and teardown errors. +- [ ] 4.5 Add artifact writers for episode config, runtime description, resolved plan, protocol trace summary, motor trace, score output, and logs. + +## 5. Fake sidecar smoke demo + +- [ ] 5.1 Implement a fake sidecar that speaks the runtime protocol, reports a deterministic whole-body motor surface, accepts motor actions, returns synthetic motor states, and provides score/artifact metadata. +- [ ] 5.2 Add a plain demo script for the fake sidecar that loads config, starts the fake sidecar, prelaunches DimOS, runs a scripted motor sequence for a fixed number of ticks, collects artifacts, and tears down both runtimes. +- [ ] 5.3 Add fake-sidecar demo config under an appropriate benchmark config directory. +- [ ] 5.4 Add automated or documented smoke validation showing the fake demo runs without Robosuite installed and writes expected artifacts. + +## 6. Robosuite sidecar integration + +- [ ] 6.1 Implement the Robosuite sidecar server package entrypoint that owns Robosuite environment construction, reset, step, scoring metadata, and artifact export. +- [ ] 6.2 Implement Robosuite task/profile resolution for baked scenes such as `Lift` with `Panda`, controller profile, control frequency, horizon, cameras, renderer options, and seed. +- [ ] 6.3 Implement runtime-derived motor surface discovery for the Panda joint-position + gripper profile, including validation against supported command modes. +- [ ] 6.4 Implement action mapping from runtime motor position frames to Robosuite action vectors and state mapping from Robosuite observations to runtime motor state frames. +- [ ] 6.5 Implement observation export for configured Robosuite camera/state observations, including at least `agentview` metadata or frame output. +- [ ] 6.6 Add sidecar tests or simulator-gated checks for profile validation, unsupported controller failure, and Robosuite action/state mapping. + +## 7. Robosuite Panda Lift plumbing demo + +- [ ] 7.1 Add a plain Robosuite Panda Lift demo script that orchestrates the Robosuite sidecar, derives the runtime plan, launches the DimOS blueprint directly, runs a scripted motor command sequence, collects score/artifacts, and tears down both runtimes. +- [ ] 7.2 Add Robosuite Panda Lift demo config with backend `robosuite`, env `Lift`, robot `Panda`, joint-position controller profile, 100 Hz requested control, horizon, seed, and camera stream settings. +- [ ] 7.3 Verify the Robosuite demo records matching motor order/count, motor state changes from scripted commands, observation frame receipt, score metadata, protocol trace summary, and cleanup status. +- [ ] 7.4 Document the sidecar environment setup and exact command to run the Robosuite demo script. + +## 8. Roadmap and validation + +- [ ] 8.1 Update `openspec/drafts/agentic-skill-benchmark-harness.md` as a roadmap document that references this change as the first concrete framework + Robosuite integration slice. +- [ ] 8.2 Add concise developer documentation for the architecture boundaries: remote runtime protocol, local SHM motor bridge, prelaunch orchestration, and sidecar package isolation. +- [ ] 8.3 Run relevant unit tests and the fake sidecar smoke demo. +- [ ] 8.4 Run or document the Robosuite Panda Lift plumbing demo validation in an environment with Robosuite available. diff --git a/openspec/drafts/agentic-skill-benchmark-harness.md b/openspec/drafts/agentic-skill-benchmark-harness.md index ff55841523..ecbbee57e2 100644 --- a/openspec/drafts/agentic-skill-benchmark-harness.md +++ b/openspec/drafts/agentic-skill-benchmark-harness.md @@ -3,6 +3,10 @@ Status: exploratory draft, not an active OpenSpec change Date: 2026-06-24 +Related concrete change: + +- `openspec/changes/framework-robosuite-integration/` defines the first implementation slice for the benchmark runtime framework plus Robosuite integration. That change intentionally focuses on protocol, prelaunch orchestration, sidecar isolation, local motor bridging, and two plumbing demos; it does not attempt the full agentic benchmark roadmap described in this draft. + ## Goal Build a DimOS-native benchmark harness for evaluating agents that solve robotics tasks by calling existing skills. @@ -340,6 +344,28 @@ Purpose: ## Implementation Phases +### Concrete Slice: `framework-robosuite-integration` + +Purpose: establish the simulator runtime substrate needed before agentic skill benchmarks can be reliable. + +Scope: + +- lightweight shared runtime protocol package outside the main `dimos` package +- network boundary between DimOS and simulator sidecars +- local SHM-only bridge between the DimOS runtime client and ControlCoordinator-facing WholeBodyAdapter +- prelaunch orchestration that starts the sidecar first, derives a resolved runtime plan, then launches a DimOS blueprint +- Robosuite Panda Lift as the first real backend plumbing demo +- fake sidecar smoke demo for dependency-light validation + +Explicitly out of scope for this slice: + +- new `dimos benchmark` CLI command +- LLM/agent task-success demo +- broad benchmark taxonomy +- code-as-policy execution + +This slice should be completed before the roadmap moves into repeatable agent skill profiles and task scoring. + ### Phase 0: Harness Design Spike Purpose: finalize the declarative task schema and identify current repo seams. From 2554d7919c51131872921fa989e994818437ee3c Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 00:02:56 -0700 Subject: [PATCH 080/110] feat: add target-conditioned VGN grasp demo --- CONTEXT.md | 20 + dimos/manipulation/grasping/grasp_gen_spec.py | 15 + dimos/manipulation/grasping/grasping.py | 66 +- .../grasping/target_grasp_demo_controller.py | 146 +++++ .../grasping/test_grasping_module.py | 131 ++++ .../grasping/test_vgn_grasp_gen_module.py | 449 +++++++++++++ .../grasping/vgn_grasp_gen_module.py | 593 ++++++++++++++++++ dimos/msgs/grasping_msgs/GraspCandidate.py | 89 +++ .../msgs/grasping_msgs/GraspCandidateArray.py | 185 ++++++ dimos/msgs/grasping_msgs/TargetBounds.py | 108 ++++ .../grasping_msgs/test_GraspCandidateArray.py | 97 +++ dimos/msgs/grasping_msgs/test_TargetBounds.py | 56 ++ .../msgs/perception_msgs/RegisteredObject.py | 102 +++ .../perception_msgs/test_RegisteredObject.py | 59 ++ .../ReconstructionStatus.py | 130 ++++ dimos/msgs/reconstruction_msgs/TSDFGrid.py | 197 ++++++ .../msgs/reconstruction_msgs/test_TSDFGrid.py | 140 +++++ dimos/perception/object_scene_registration.py | 72 +++ .../object_scene_registration_spec.py | 11 + dimos/perception/reconstruction/__init__.py | 20 + .../reconstruction/scene_reconstruction.py | 366 +++++++++++ .../test_scene_reconstruction.py | 167 +++++ .../reconstruction/test_tsdf_debug_export.py | 55 ++ .../reconstruction/tsdf_debug_export.py | 88 +++ ...ct_scene_registration_registered_object.py | 97 +++ dimos/robot/all_blueprints.py | 3 + .../xarm/blueprints/simulation.py | 46 ++ .../blueprints/test_vgn_mujoco_grasp_demo.py | 85 +++ dimos/simulation/engines/mujoco_engine.py | 35 +- dimos/simulation/engines/mujoco_sim_module.py | 8 +- .../engines/test_robot_sim_binding.py | 14 + docs/usage/README.md | 1 + docs/usage/vgn_mujoco_grasp_demo.md | 36 ++ .../vgn-mujoco-grasp-demo/.openspec.yaml | 2 - .../changes/vgn-mujoco-grasp-demo/design.md | 97 --- .../changes/vgn-mujoco-grasp-demo/proposal.md | 26 - .../changes/vgn-mujoco-grasp-demo/tasks.md | 29 - .../.openspec.yaml | 2 - .../vgn-tsdf-scene-reconstruction/design.md | 92 --- .../vgn-tsdf-scene-reconstruction/proposal.md | 29 - .../vgn-tsdf-scene-reconstruction/tasks.md | 34 - .../specs/registered-object-targeting/spec.md | 34 + .../specs/scene-reconstruction/spec.md | 0 .../spec.md | 52 ++ .../specs/target-conditioned-vgn-demo/spec.md | 61 ++ .../specs/tsdf-grasp-generation/spec.md | 0 .../specs/vgn-mujoco-grasp-demo/spec.md | 0 47 files changed, 3829 insertions(+), 316 deletions(-) create mode 100644 dimos/manipulation/grasping/target_grasp_demo_controller.py create mode 100644 dimos/manipulation/grasping/test_grasping_module.py create mode 100644 dimos/manipulation/grasping/test_vgn_grasp_gen_module.py create mode 100644 dimos/manipulation/grasping/vgn_grasp_gen_module.py create mode 100644 dimos/msgs/grasping_msgs/GraspCandidate.py create mode 100644 dimos/msgs/grasping_msgs/GraspCandidateArray.py create mode 100644 dimos/msgs/grasping_msgs/TargetBounds.py create mode 100644 dimos/msgs/grasping_msgs/test_GraspCandidateArray.py create mode 100644 dimos/msgs/grasping_msgs/test_TargetBounds.py create mode 100644 dimos/msgs/perception_msgs/RegisteredObject.py create mode 100644 dimos/msgs/perception_msgs/test_RegisteredObject.py create mode 100644 dimos/msgs/reconstruction_msgs/ReconstructionStatus.py create mode 100644 dimos/msgs/reconstruction_msgs/TSDFGrid.py create mode 100644 dimos/msgs/reconstruction_msgs/test_TSDFGrid.py create mode 100644 dimos/perception/reconstruction/__init__.py create mode 100644 dimos/perception/reconstruction/scene_reconstruction.py create mode 100644 dimos/perception/reconstruction/test_scene_reconstruction.py create mode 100644 dimos/perception/reconstruction/test_tsdf_debug_export.py create mode 100644 dimos/perception/reconstruction/tsdf_debug_export.py create mode 100644 dimos/perception/test_object_scene_registration_registered_object.py create mode 100644 dimos/robot/manipulators/xarm/blueprints/test_vgn_mujoco_grasp_demo.py create mode 100644 docs/usage/vgn_mujoco_grasp_demo.md delete mode 100644 openspec/changes/vgn-mujoco-grasp-demo/.openspec.yaml delete mode 100644 openspec/changes/vgn-mujoco-grasp-demo/design.md delete mode 100644 openspec/changes/vgn-mujoco-grasp-demo/proposal.md delete mode 100644 openspec/changes/vgn-mujoco-grasp-demo/tasks.md delete mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/.openspec.yaml delete mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/design.md delete mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/proposal.md delete mode 100644 openspec/changes/vgn-tsdf-scene-reconstruction/tasks.md create mode 100644 openspec/specs/registered-object-targeting/spec.md rename openspec/{changes/vgn-tsdf-scene-reconstruction => }/specs/scene-reconstruction/spec.md (100%) create mode 100644 openspec/specs/target-conditioned-tsdf-grasp-generation/spec.md create mode 100644 openspec/specs/target-conditioned-vgn-demo/spec.md rename openspec/{changes/vgn-tsdf-scene-reconstruction => }/specs/tsdf-grasp-generation/spec.md (100%) rename openspec/{changes/vgn-mujoco-grasp-demo => }/specs/vgn-mujoco-grasp-demo/spec.md (100%) diff --git a/CONTEXT.md b/CONTEXT.md index cad259d7dd..18eca08b80 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -32,6 +32,26 @@ _Avoid_: plugin, embedded simulator A depth image interpreted with its camera calibration and the pose of its camera frame at the image timestamp. _Avoid_: depth point cloud, raw 3D points +**Grasp target**: +The intended physical object or bounded scene region that grasp generation should produce grasp candidates for. +_Avoid_: correct object, target object, grasp object + +**Object id**: +A stable identifier for a registered perceived object, used when a robot command must refer to one non-ambiguous physical object. +_Avoid_: object-ish argument, object name when identity matters + +**Registered object**: +A perceived object that has been assigned an Object id and has enough spatial metadata to be used as a Grasp target. +_Avoid_: detection when referring to a persistent object reference + +**Target-masked TSDF**: +A grasp-generation workspace representation where observations outside the selected Grasp target are suppressed with a deliberate cushion so the target remains intact. +_Avoid_: censored TSDF, object-only scene + +**Target bounds**: +A world-frame axis-aligned bounding region used as a rough attention area for a Grasp target before grasp generation. +_Avoid_: perfect object geometry, grasp geometry + **SHM runtime data plane**: A shared-memory command/state channel between a simulated hardware adapter and a simulator runtime, used when high-rate control must cross process boundaries without RPC. _Avoid_: public simulator API, benchmark control plane, module object sharing diff --git a/dimos/manipulation/grasping/grasp_gen_spec.py b/dimos/manipulation/grasping/grasp_gen_spec.py index 37c81c85bc..1ae8d01eb5 100644 --- a/dimos/manipulation/grasping/grasp_gen_spec.py +++ b/dimos/manipulation/grasping/grasp_gen_spec.py @@ -15,6 +15,9 @@ from typing import Protocol from dimos.msgs.geometry_msgs.PoseArray import PoseArray +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray +from dimos.msgs.reconstruction_msgs.TSDFGrid import TSDFGrid from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 from dimos.spec.utils import Spec @@ -25,3 +28,15 @@ def generate_grasps( pointcloud: PointCloud2, scene_pointcloud: PointCloud2 | None = None, ) -> PoseArray | None: ... + + +class TSDFGraspGenSpec(Spec, Protocol): + def generate_grasps_from_tsdf(self, tsdf: TSDFGrid) -> GraspCandidateArray | None: ... + def generate_grasps_for_target_bounds( + self, + target_center: Vector3, + target_size: Vector3, + target_frame_id: str, + target_ts: float, + cushion_m: float = 0.03, + ) -> GraspCandidateArray | None: ... diff --git a/dimos/manipulation/grasping/grasping.py b/dimos/manipulation/grasping/grasping.py index 0314664f9b..555962d738 100644 --- a/dimos/manipulation/grasping/grasping.py +++ b/dimos/manipulation/grasping/grasping.py @@ -25,7 +25,7 @@ from dimos.core.core import rpc from dimos.core.module import Module from dimos.core.stream import Out -from dimos.manipulation.grasping.grasp_gen_spec import GraspGenSpec +from dimos.manipulation.grasping.grasp_gen_spec import GraspGenSpec, TSDFGraspGenSpec from dimos.msgs.geometry_msgs.PoseArray import PoseArray from dimos.perception.object_scene_registration_spec import ObjectSceneRegistrationSpec from dimos.utils.logging_config import setup_logger @@ -43,7 +43,8 @@ class GraspingModule(Module): grasps: Out[PoseArray] _scene_registration: ObjectSceneRegistrationSpec - _grasp_gen: GraspGenSpec + _grasp_gen: GraspGenSpec | None = None + _tsdf_grasp_gen: TSDFGraspGenSpec | None = None @rpc def start(self) -> None: @@ -85,6 +86,10 @@ def generate_grasps( # Call GraspGenModule (running in Docker) try: + if self._grasp_gen is None: + msg = "Pointcloud grasp generator is not available." + logger.warning(msg) + return msg result = self._grasp_gen.generate_grasps(pc, scene_pc) except Exception as e: msg = f"Grasp generation failed: {e}" @@ -102,6 +107,63 @@ def generate_grasps( # Format result for agent/human return self._format_grasp_result(result, object_name) + @skill + def generate_grasps_for_object(self, object_id: str, cushion_m: float = 0.03) -> str: + """Generate TSDF grasp candidates for a specific registered object id. + + Args: + object_id: Stable runtime object id returned by perception after detection. + cushion_m: Extra padding around object bounds in meters. + """ + if self._tsdf_grasp_gen is None: + msg = "TSDF grasp generator is not available." + logger.warning(msg) + return msg + + try: + target = self._scene_registration.get_object_by_object_id(object_id) + except Exception as exc: + msg = f"Failed to look up registered object '{object_id}': {exc}" + logger.error(msg) + return msg + + if target is None: + msg = f"No registered object found with object_id '{object_id}'." + logger.warning(msg) + return msg + + try: + candidates = self._tsdf_grasp_gen.generate_grasps_for_target_bounds( + target_center=target.center, + target_size=target.size, + target_frame_id=target.frame_id, + target_ts=target.ts, + cushion_m=cushion_m, + ) + except Exception as exc: + msg = f"Target-conditioned grasp generation failed for '{object_id}': {exc}" + logger.error(msg) + return msg + + if candidates is None: + msg = f"No target-conditioned grasps generated for '{target.name}' ({object_id})." + logger.info(msg) + return msg + if len(candidates) == 0: + msg = f"VGN returned no target-conditioned grasps for '{target.name}' ({object_id})." + logger.info(msg) + return msg + + poses = candidates.to_pose_array() + self.grasps.publish(poses) + logger.info( + "Generated %s target-conditioned grasps for '%s' (%s)", + len(candidates), + target.name, + object_id, + ) + return self._format_grasp_result(poses, target.name) + def _get_object_pointcloud( self, object_name: str, object_id: str | None = None ) -> PointCloud2 | None: diff --git a/dimos/manipulation/grasping/target_grasp_demo_controller.py b/dimos/manipulation/grasping/target_grasp_demo_controller.py new file mode 100644 index 0000000000..97dedfa637 --- /dev/null +++ b/dimos/manipulation/grasping/target_grasp_demo_controller.py @@ -0,0 +1,146 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import threading +import time +from typing import Protocol + +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import Out +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.TargetBounds import TargetBounds +from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject +from dimos.perception.object_scene_registration_spec import ObjectSceneRegistrationSpec +from dimos.spec.utils import Spec +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + + +class TargetGraspingSpec(Spec, Protocol): + def generate_grasps_for_object(self, object_id: str, cushion_m: float = 0.03) -> str: ... + + +class TargetGraspDemoControllerConfig(ModuleConfig): + target_name: str = "orange" + cushion_m: float = 0.015 + detection_timeout_s: float = 45.0 + tsdf_settle_s: float = 4.0 + retry_interval_s: float = 0.5 + workspace_center: tuple[float, float, float] = (0.45, 0.0, 0.18) + + +class TargetGraspDemoController(Module): + """Run the opt-in target-conditioned VGN demo sequence.""" + + config: TargetGraspDemoControllerConfig + + grasp_target_bounds: Out[TargetBounds] + + _scene_registration: ObjectSceneRegistrationSpec + _grasping: TargetGraspingSpec + + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + + @rpc + def start(self) -> None: + super().start() + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run_demo, + name="TargetGraspDemoController", + daemon=True, + ) + self._thread.start() + + @rpc + def stop(self) -> None: + self._stop_event.set() + super().stop() + + def _run_demo(self) -> None: + target_name = self.config.target_name + logger.info("Starting target-conditioned grasp demo for '%s'", target_name) + self._scene_registration.set_prompts(text=[target_name]) + target = self._wait_for_target(target_name) + if target is None: + logger.warning( + "No registered object matched target '%s'; not falling back to workspace VGN", + target_name, + ) + self._publish_empty_target_bounds(target_name) + return + + logger.info( + "Selected runtime object id=%s name=%s for target-conditioned grasp demo", + target.object_id, + target.name, + ) + self._publish_target_bounds(target) + self._wait(self.config.tsdf_settle_s) + if self._stop_event.is_set(): + return + result = self._grasping.generate_grasps_for_object( + target.object_id, + cushion_m=self.config.cushion_m, + ) + logger.info("Target-conditioned grasp demo result: %s", result) + + def _wait_for_target(self, target_name: str) -> RegisteredObject | None: + deadline = time.time() + self.config.detection_timeout_s + while time.time() < deadline and not self._stop_event.is_set(): + matches = [ + obj + for obj in self._scene_registration.get_registered_objects() + if obj.name.lower() == target_name.lower() + ] + if matches: + return min(matches, key=self._workspace_distance) + self._wait(self.config.retry_interval_s) + return None + + def _workspace_distance(self, obj: RegisteredObject) -> float: + cx, cy, cz = self.config.workspace_center + center = Vector3(cx, cy, cz) + return center.distance(obj.center) + + def _wait(self, duration_s: float) -> None: + self._stop_event.wait(max(0.0, duration_s)) + + def _publish_target_bounds(self, target: RegisteredObject) -> None: + self.grasp_target_bounds.publish( + TargetBounds( + center=target.center, + size=target.size, + frame_id=target.frame_id, + ts=target.ts, + label=f"{target.name}:{target.object_id}", + ) + ) + + def _publish_empty_target_bounds(self, target_name: str) -> None: + self.grasp_target_bounds.publish( + TargetBounds( + center=Vector3(), + size=Vector3(), + frame_id="world", + label=f"no target: {target_name}", + ) + ) diff --git a/dimos/manipulation/grasping/test_grasping_module.py b/dimos/manipulation/grasping/test_grasping_module.py new file mode 100644 index 0000000000..9fa06ce05a --- /dev/null +++ b/dimos/manipulation/grasping/test_grasping_module.py @@ -0,0 +1,131 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Generator + +import pytest +from pytest_mock import MockerFixture + +from dimos.manipulation.grasping.grasping import GraspingModule +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.GraspCandidate import GraspCandidate +from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray +from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject +from dimos.msgs.std_msgs.Header import Header + + +@pytest.fixture(autouse=True) +def _stop_created_modules(mocker: MockerFixture) -> Generator[None, None, None]: + modules: list[GraspingModule] = [] + original_init = GraspingModule.__init__ + + def tracked_init(self: GraspingModule, **kwargs: object) -> None: + original_init(self, **kwargs) + modules.append(self) + + mocker.patch.object(GraspingModule, "__init__", tracked_init) + yield + for module in modules: + module.stop() + + +def _candidate_array() -> GraspCandidateArray: + return GraspCandidateArray( + header=Header(12.5, "world"), + candidates=[ + GraspCandidate( + pose=Pose(Vector3(1.0, 2.0, 3.0), Quaternion(0.0, 0.0, 0.0, 1.0)), + jaw_width=0.05, + score=0.9, + id="vgn-0", + ) + ], + ) + + +def test_generate_grasps_for_object_unknown_object_id_returns_clear_message( + mocker: MockerFixture, +) -> None: + module = GraspingModule() + scene_registration = mocker.Mock() + scene_registration.get_object_by_object_id.return_value = None + tsdf_grasp_gen = mocker.Mock() + mocker.patch.object(module, "_scene_registration", scene_registration, create=True) + mocker.patch.object(module, "_tsdf_grasp_gen", tsdf_grasp_gen, create=True) + + message = module.generate_grasps_for_object("missing-object") + + assert message == "No registered object found with object_id 'missing-object'." + tsdf_grasp_gen.generate_grasps_for_target_bounds.assert_not_called() + + +def test_generate_grasps_for_object_forwards_bounds_and_publishes_pose_array( + mocker: MockerFixture, +) -> None: + module = GraspingModule() + target = RegisteredObject( + object_id="obj-1", + name="mug", + center=Vector3(0.1, 0.2, 0.3), + size=Vector3(0.4, 0.5, 0.6), + frame_id="camera", + ts=42.0, + ) + scene_registration = mocker.Mock() + scene_registration.get_object_by_object_id.return_value = target + candidates = _candidate_array() + tsdf_grasp_gen = mocker.Mock() + tsdf_grasp_gen.generate_grasps_for_target_bounds.return_value = candidates + mocker.patch.object(module, "_scene_registration", scene_registration, create=True) + mocker.patch.object(module, "_tsdf_grasp_gen", tsdf_grasp_gen, create=True) + published = [] + module.grasps.subscribe(published.append) + + message = module.generate_grasps_for_object("obj-1", cushion_m=0.07) + + tsdf_grasp_gen.generate_grasps_for_target_bounds.assert_called_once_with( + target_center=target.center, + target_size=target.size, + target_frame_id="camera", + target_ts=42.0, + cushion_m=0.07, + ) + assert len(published) == 1 + assert published[0].poses == candidates.to_pose_array().poses + assert "Generated 1" in message + + +def test_generate_grasps_pointcloud_flow_unchanged(mocker: MockerFixture) -> None: + module = GraspingModule() + scene_registration = mocker.Mock() + object_pc = mocker.Mock() + scene_pc = mocker.Mock() + scene_registration.get_object_pointcloud_by_object_id.return_value = object_pc + scene_registration.get_full_scene_pointcloud.return_value = scene_pc + grasp_gen = mocker.Mock() + grasp_gen.generate_grasps.return_value = _candidate_array().to_pose_array() + mocker.patch.object(module, "_scene_registration", scene_registration, create=True) + mocker.patch.object(module, "_grasp_gen", grasp_gen, create=True) + published = [] + module.grasps.subscribe(published.append) + + message = module.generate_grasps(object_name="mug", object_id="obj-1", filter_collisions=True) + + scene_registration.get_object_pointcloud_by_object_id.assert_called_once_with("obj-1") + scene_registration.get_full_scene_pointcloud.assert_called_once_with(exclude_object_id="obj-1") + grasp_gen.generate_grasps.assert_called_once_with(object_pc, scene_pc) + assert len(published) == 1 + assert "Generated 1" in message diff --git a/dimos/manipulation/grasping/test_vgn_grasp_gen_module.py b/dimos/manipulation/grasping/test_vgn_grasp_gen_module.py new file mode 100644 index 0000000000..318073259e --- /dev/null +++ b/dimos/manipulation/grasping/test_vgn_grasp_gen_module.py @@ -0,0 +1,449 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import builtins +from collections.abc import Generator, Sequence +from dataclasses import dataclass +import os +from types import ModuleType +from typing import Protocol, cast + +import numpy as np +import pytest +from pytest_mock import MockerFixture + +from dimos.manipulation.grasping.grasp_gen_spec import TSDFGraspGenSpec +from dimos.manipulation.grasping.vgn_grasp_gen_module import ( + VGNGraspGenModule, + _load_vgn_network, + _select_torch_device, +) +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Transform import Transform +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray +from dimos.msgs.grasping_msgs.TargetBounds import TargetBounds +from dimos.msgs.reconstruction_msgs.TSDFGrid import TSDFGrid +from dimos.spec.utils import spec_annotation_compliance, spec_structural_compliance + + +@dataclass(slots=True) +class _FakeGrasp: + pose: np.ndarray + width: float = 0.06 + + +class _TSDFAdapterLike(Protocol): + voxel_size: float + + def get_grid(self) -> np.ndarray: ... + + +class _StateWithTSDF(Protocol): + tsdf: _TSDFAdapterLike + + +class _FakeDetector: + def __init__(self, grasps: Sequence[_FakeGrasp], scores: Sequence[float]) -> None: + self._grasps = grasps + self._scores = scores + self.seen_grid_shape: tuple[int, ...] | None = None + self.seen_voxel_size: float | None = None + + def __call__(self, state: object) -> tuple[Sequence[_FakeGrasp], Sequence[float], float]: + tsdf = cast("_StateWithTSDF", state).tsdf + self.seen_grid_shape = tsdf.get_grid().shape + self.seen_voxel_size = tsdf.voxel_size + return self._grasps, self._scores, 0.0 + + +class _FakeTorch: + def __init__(self) -> None: + self.loaded_path: object | None = None + self.loaded_map_location: str | None = None + self.empty_device: str | None = None + self.cuda = _FakeCuda(self) + + def device(self, name: str) -> str: + return name + + def load(self, path: object, *, map_location: str) -> dict[str, int]: + self.loaded_path = path + self.loaded_map_location = map_location + return {"weight": 1} + + def empty(self, size: int, *, device: str) -> list[int]: + self.empty_device = device + return [0] * size + + +class _FakeCuda: + def __init__(self, torch: _FakeTorch) -> None: + self._torch = torch + self.available = True + self.count = 1 + self.synchronized = False + + def is_available(self) -> bool: + return self.available + + def device_count(self) -> int: + return self.count + + def synchronize(self) -> None: + self.synchronized = True + + +class _FakeNetwork: + def __init__(self) -> None: + self.state_dict: dict[str, int] | None = None + self.device: object | None = None + self.evaluated = False + + def load_state_dict(self, state_dict: dict[str, int]) -> None: + self.state_dict = state_dict + + def to(self, device: object) -> _FakeNetwork: + self.device = device + return self + + def eval(self) -> None: + self.evaluated = True + + +def _tsdf_grid( + *, + shape: tuple[int, int, int, int] = (1, 40, 40, 40), + frame_id: str = "world", + ts: float = 10.0, +) -> TSDFGrid: + return TSDFGrid( + distances=np.ones(shape, dtype=np.float32), + weights=np.ones(shape[1:], dtype=np.float32), + voxel_size=0.01, + truncation_distance=0.04, + origin=Vector3(0.1, 0.2, 0.3), + frame_id=frame_id, + ts=ts, + ) + + +def _grasp_matrix(translation: tuple[float, float, float]) -> np.ndarray: + matrix = np.eye(4, dtype=np.float64) + matrix[:3, 3] = np.array(translation, dtype=np.float64) + return matrix + + +@pytest.fixture(autouse=True) +def _stop_created_modules(mocker: MockerFixture) -> Generator[None, None, None]: + modules: list[VGNGraspGenModule] = [] + original_init = VGNGraspGenModule.__init__ + + def tracked_init(self: VGNGraspGenModule, **kwargs: object) -> None: + original_init(self, **kwargs) + modules.append(self) + + mocker.patch.object(VGNGraspGenModule, "__init__", tracked_init) + yield + for module in modules: + module.stop() + + +def test_invalid_tsdf_shape_is_rejected() -> None: + module = VGNGraspGenModule() + + with pytest.raises(ValueError, match="VGN expects TSDF shape"): + module.generate_grasps_from_tsdf(_tsdf_grid(shape=(1, 20, 20, 20))) + + +def test_vgn_module_implements_tsdf_grasp_gen_spec() -> None: + module = VGNGraspGenModule() + + assert spec_structural_compliance(module, TSDFGraspGenSpec) + assert spec_annotation_compliance(module, TSDFGraspGenSpec) + + +def test_missing_model_path_fails_before_import(mocker) -> None: # type: ignore[no-untyped-def] + module = VGNGraspGenModule(model_path_env="DIMOS_TEST_VGN_MODEL_PATH") + import_spy = mocker.spy(builtins, "__import__") + mocker.patch.dict(os.environ, {"DIMOS_TEST_VGN_MODEL_PATH": ""}, clear=False) + + with pytest.raises(RuntimeError, match="VGN model path is required"): + module.generate_grasps_from_tsdf(_tsdf_grid()) + + imported_names = [call.args[0] for call in import_spy.call_args_list if call.args] + assert "vgn.detection" not in imported_names + + +def test_missing_model_file_reports_path(tmp_path) -> None: # type: ignore[no-untyped-def] + missing_model = tmp_path / "missing" / "vgn_conv.pth" + module = VGNGraspGenModule(model_path=str(missing_model)) + + with pytest.raises(RuntimeError, match="VGN model file does not exist"): + module.generate_grasps_from_tsdf(_tsdf_grid()) + + +def test_ros_visualization_import_failure_uses_headless_detector( + mocker: MockerFixture, tmp_path +) -> None: # type: ignore[no-untyped-def] + model_path = tmp_path / "vgn_conv.pth" + model_path.write_bytes(b"checkpoint") + module = VGNGraspGenModule(model_path=str(model_path)) + detector = _FakeDetector([], []) + mocker.patch( + "dimos.manipulation.grasping.vgn_grasp_gen_module._HeadlessVGNDetector", + return_value=detector, + ) + mocker.patch( + "dimos.manipulation.grasping.vgn_grasp_gen_module.importlib.util.find_spec", + return_value=object(), + ) + original_import = builtins.__import__ + + def fake_import( + name: str, + globals: dict[str, object] | None = None, + locals: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> ModuleType: + if name == "vgn.detection": + raise ModuleNotFoundError("No module named 'sensor_msgs'", name="sensor_msgs") + return original_import(name, globals, locals, fromlist, level) + + mocker.patch.object(builtins, "__import__", side_effect=fake_import) + + assert module._get_detector() is detector + + +def test_headless_loader_loads_checkpoint_on_cpu_before_moving_to_device(tmp_path) -> None: # type: ignore[no-untyped-def] + fake_torch = _FakeTorch() + network = _FakeNetwork() + model_path = tmp_path / "vgn_conv.pth" + model_path.write_bytes(b"checkpoint") + requested_models: list[str] = [] + + def get_network(model_name: str) -> _FakeNetwork: + requested_models.append(model_name) + return network + + result = _load_vgn_network(fake_torch, get_network, model_path, "cuda:0") + + assert result is network + assert requested_models == ["conv"] + assert fake_torch.loaded_path == model_path + assert fake_torch.loaded_map_location == "cpu" + assert network.state_dict == {"weight": 1} + assert network.device == "cuda:0" + assert network.evaluated is True + + +def test_torch_device_selection_smoke_tests_cuda() -> None: + fake_torch = _FakeTorch() + + assert _select_torch_device(fake_torch) == "cuda:0" + assert fake_torch.empty_device == "cuda:0" + assert fake_torch.cuda.synchronized is True + + +def test_torch_device_selection_falls_back_to_cpu_when_cuda_unusable() -> None: + fake_torch = _FakeTorch() + fake_torch.cuda.available = True + fake_torch.cuda.count = 0 + + assert _select_torch_device(fake_torch) == "cpu" + + +def test_fake_detector_converts_origin_and_publishes_outputs(mocker) -> None: # type: ignore[no-untyped-def] + module = VGNGraspGenModule(output_frame="world") + detector = _FakeDetector([_FakeGrasp(_grasp_matrix((0.01, 0.02, 0.03)))], [0.9]) + mocker.patch.object(module, "_detector", detector) + candidates_out: list[GraspCandidateArray] = [] + poses_out = [] + module.grasp_candidates.subscribe(candidates_out.append) + module.grasp_poses.subscribe(poses_out.append) + + result = module.generate_grasps_from_tsdf(_tsdf_grid()) + + assert result is not None + assert detector.seen_grid_shape == (1, 40, 40, 40) + assert detector.seen_voxel_size == pytest.approx(0.01) + assert len(result) == 1 + candidate = result[0] + assert candidate.id == "vgn-0" + assert candidate.score == pytest.approx(0.9) + assert candidate.jaw_width == pytest.approx(0.06) + assert candidate.pose.position.x == pytest.approx(0.11) + assert candidate.pose.position.y == pytest.approx(0.22) + assert candidate.pose.position.z == pytest.approx(0.33) + assert candidates_out == [result] + assert len(poses_out) == 1 + assert len(poses_out[0].poses) == 1 + + +def test_missing_world_transform_returns_none_and_does_not_publish(mocker) -> None: # type: ignore[no-untyped-def] + module = VGNGraspGenModule(output_frame="world") + mocker.patch.object( + module, + "_detector", + _FakeDetector([_FakeGrasp(_grasp_matrix((0.01, 0.02, 0.03)))], [0.9]), + ) + get_transform = mocker.patch.object(module.tf, "get", return_value=None) + candidates_out: list[GraspCandidateArray] = [] + module.grasp_candidates.subscribe(candidates_out.append) + + result = module.generate_grasps_from_tsdf(_tsdf_grid(frame_id="camera")) + + assert result is None + assert candidates_out == [] + get_transform.assert_called_once_with("world", "camera", 10.0, 0.1) + + +def test_world_transform_is_applied(mocker) -> None: # type: ignore[no-untyped-def] + module = VGNGraspGenModule(output_frame="world") + mocker.patch.object( + module, + "_detector", + _FakeDetector([_FakeGrasp(_grasp_matrix((0.01, 0.02, 0.03)))], [0.9]), + ) + transform = Transform( + translation=Vector3(1.0, 2.0, 3.0), + rotation=Quaternion([0.0, 0.0, 0.0, 1.0]), + frame_id="world", + child_frame_id="camera", + ts=10.0, + ) + mocker.patch.object(module.tf, "get", return_value=transform) + + result = module.generate_grasps_from_tsdf(_tsdf_grid(frame_id="camera")) + + assert result is not None + assert result[0].pose.position.x == pytest.approx(1.11) + assert result[0].pose.position.y == pytest.approx(2.22) + assert result[0].pose.position.z == pytest.approx(3.33) + + +def test_generate_latest_without_tsdf_publishes_empty_candidates() -> None: + module = VGNGraspGenModule(output_frame="world") + candidates_out: list[GraspCandidateArray] = [] + module.grasp_candidates.subscribe(candidates_out.append) + + message = module.generate_latest_grasps() + + assert message == "No TSDF available for grasp generation" + assert len(candidates_out) == 1 + assert len(candidates_out[0]) == 0 + assert candidates_out[0].frame_id == "world" + + +def test_target_bounds_without_latest_tsdf_clears_candidates_without_result() -> None: + module = VGNGraspGenModule(output_frame="world") + candidates_out: list[GraspCandidateArray] = [] + module.grasp_candidates.subscribe(candidates_out.append) + + result = module.generate_grasps_for_target_bounds( + target_center=Vector3(0.2, 0.3, 0.4), + target_size=Vector3(0.1, 0.1, 0.1), + target_frame_id="world", + target_ts=12.0, + ) + + assert result is None + assert len(candidates_out) == 1 + assert len(candidates_out[0]) == 0 + assert candidates_out[0].frame_id == "world" + + +def test_target_mask_does_not_mutate_original_and_suppresses_outside_voxels() -> None: + module = VGNGraspGenModule(output_frame="world") + tsdf = _tsdf_grid() + tsdf.distances.fill(0.25) + assert tsdf.weights is not None + original_distances = tsdf.distances.copy() + original_weights = tsdf.weights.copy() + bounds = TargetBounds( + center=Vector3(0.1, 0.2, 0.3), + size=Vector3(0.01, 0.01, 0.01), + frame_id="world", + ts=tsdf.ts, + ) + + masked, bounds_in_tsdf = module._target_masked_tsdf(tsdf, bounds, cushion_m=0.0) + + assert masked is not None + assert bounds_in_tsdf is not None + assert masked.weights is not None + assert np.array_equal(tsdf.distances, original_distances) + assert np.array_equal(tsdf.weights, original_weights) + assert masked.distances[0, 0, 0, 0] == pytest.approx(0.25) + assert masked.distances[0, 1, 0, 0] == pytest.approx(1.0) + assert masked.weights[0, 0, 0] == pytest.approx(1.0) + assert masked.weights[1, 0, 0] == pytest.approx(0.0) + + +def test_target_bounds_transform_failure_returns_none_and_clears(mocker: MockerFixture) -> None: + module = VGNGraspGenModule(output_frame="world") + module._latest_tsdf = _tsdf_grid(frame_id="tsdf") + mocker.patch.object(module.tf, "get", return_value=None) + candidates_out: list[GraspCandidateArray] = [] + module.grasp_candidates.subscribe(candidates_out.append) + + result = module.generate_grasps_for_target_bounds( + target_center=Vector3(0.0, 0.0, 0.0), + target_size=Vector3(0.1, 0.1, 0.1), + target_frame_id="object", + target_ts=12.0, + ) + + assert result is None + assert len(candidates_out) == 1 + assert len(candidates_out[0]) == 0 + + +def test_target_bounds_path_publishes_only_filtered_candidates(mocker: MockerFixture) -> None: + module = VGNGraspGenModule(output_frame="world") + module._latest_tsdf = _tsdf_grid() + mocker.patch.object( + module, + "_detector", + _FakeDetector( + [ + _FakeGrasp(_grasp_matrix((0.0, 0.0, 0.0))), + _FakeGrasp(_grasp_matrix((0.25, 0.25, 0.25))), + ], + [0.95, 0.9], + ), + ) + candidates_out: list[GraspCandidateArray] = [] + bounds_out: list[TargetBounds] = [] + masked_out: list[TSDFGrid] = [] + module.grasp_candidates.subscribe(candidates_out.append) + module.grasp_target_bounds.subscribe(bounds_out.append) + module.target_masked_tsdf.subscribe(masked_out.append) + + result = module.generate_grasps_for_target_bounds( + target_center=Vector3(0.1, 0.2, 0.3), + target_size=Vector3(0.04, 0.04, 0.04), + target_frame_id="world", + target_ts=10.0, + cushion_m=0.0, + ) + + assert result is not None + assert [candidate.id for candidate in result] == ["vgn-0"] + assert candidates_out == [result] + assert len(bounds_out) == 1 + assert len(masked_out) == 1 diff --git a/dimos/manipulation/grasping/vgn_grasp_gen_module.py b/dimos/manipulation/grasping/vgn_grasp_gen_module.py new file mode 100644 index 0000000000..8eab08b321 --- /dev/null +++ b/dimos/manipulation/grasping/vgn_grasp_gen_module.py @@ -0,0 +1,593 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Sequence +import importlib.util +import os +from pathlib import Path +import time +from typing import Protocol, cast + +import numpy as np +from reactivex.disposable import Disposable + +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import In, Out +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseArray import PoseArray +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.GraspCandidate import GraspCandidate +from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray +from dimos.msgs.grasping_msgs.TargetBounds import TargetBounds +from dimos.msgs.reconstruction_msgs.TSDFGrid import TSDFGrid +from dimos.msgs.std_msgs.Header import Header +from dimos.perception.reconstruction.tsdf_debug_export import export_tsdf_debug_files +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + + +class VGNGraspGenModuleConfig(ModuleConfig): + model_path: str | None = None + model_path_env: str = "DIMOS_VGN_MODEL_PATH" + output_frame: str = "world" + default_jaw_width: float = 0.08 + min_score: float = 0.0 + auto_generate_on_tsdf: bool = False + auto_generate_min_interval: float = 1.0 + debug_export_dir: str | None = None + + +class _VGNDetector(Protocol): + def __call__(self, state: _VGNState) -> tuple[Sequence[object], Sequence[float], float]: ... + + +class _TorchNetwork(Protocol): + def __call__(self, tensor: object) -> tuple[object, object, object]: ... + + +class _TorchTensor(Protocol): + def cpu(self) -> _TorchTensor: ... + + def squeeze(self) -> _TorchTensor: ... + + def numpy(self) -> np.ndarray: ... + + +class _VGNTSDFAdapter: + def __init__(self, tsdf: TSDFGrid) -> None: + self.voxel_size = tsdf.voxel_size + self._grid = tsdf.distances + + def get_grid(self) -> np.ndarray: + return self._grid + + +class _VGNState: + def __init__(self, tsdf: TSDFGrid) -> None: + self.tsdf = _VGNTSDFAdapter(tsdf) + + +class VGNGraspGenModule(Module): + """Generate grasp candidates from TSDF grids using the VGN backend.""" + + dedicated_worker = True + + config: VGNGraspGenModuleConfig + + tsdf: In[TSDFGrid] + + grasp_candidates: Out[GraspCandidateArray] + grasp_poses: Out[PoseArray] + grasp_target_bounds: Out[TargetBounds] + target_masked_tsdf: Out[TSDFGrid] + + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) + self._latest_tsdf: TSDFGrid | None = None + self._detector: _VGNDetector | None = None + self._last_auto_generation_ts: float | None = None + + @rpc + def start(self) -> None: + super().start() + self.register_disposable(Disposable(self.tsdf.subscribe(self._on_tsdf))) + + @rpc + def generate_grasps_from_tsdf(self, tsdf: TSDFGrid) -> GraspCandidateArray | None: + """Generate grasp candidates from an explicit TSDF grid.""" + result = self._generate_grasps_from_tsdf(tsdf) + if result is not None: + self.grasp_candidates.publish(result) + self.grasp_poses.publish(result.to_pose_array()) + return result + + def _generate_grasps_from_tsdf(self, tsdf: TSDFGrid) -> GraspCandidateArray | None: + self._validate_tsdf(tsdf) + try: + grasps, scores, _toc = self._get_detector()(_VGNState(tsdf)) + except Exception as exc: + raise RuntimeError(f"VGN grasp generation failed: {exc}") from exc + return self._convert_grasps(tsdf, grasps, scores) + + @rpc + def generate_latest_grasps(self) -> str: + """Generate grasp candidates from the latest TSDF received on the stream.""" + if self._latest_tsdf is None: + empty = _make_candidate_array([], self.config.output_frame) + self.grasp_candidates.publish(empty) + return "No TSDF available for grasp generation" + result = self.generate_grasps_from_tsdf(self._latest_tsdf) + if result is None: + return "Failed to generate world-frame grasp candidates" + if len(result) == 0: + return "VGN returned no grasp candidates" + return f"Generated {len(result)} grasp candidates" + + @rpc + def generate_grasps_for_target_bounds( + self, + target_center: Vector3, + target_size: Vector3, + target_frame_id: str, + target_ts: float, + cushion_m: float = 0.03, + ) -> GraspCandidateArray | None: + """Generate candidates from the latest TSDF constrained to target bounds.""" + if self._latest_tsdf is None: + empty = _make_candidate_array([], self.config.output_frame) + self.grasp_candidates.publish(empty) + return None + + bounds = TargetBounds( + center=target_center, + size=target_size, + frame_id=target_frame_id, + ts=target_ts, + label="grasp target", + ) + masked_tsdf, bounds_in_tsdf = self._target_masked_tsdf( + self._latest_tsdf, + bounds, + cushion_m, + ) + if masked_tsdf is None or bounds_in_tsdf is None: + empty = _make_candidate_array([], self.config.output_frame, self._latest_tsdf.ts) + self.grasp_candidates.publish(empty) + return None + + self.grasp_target_bounds.publish(bounds_in_tsdf) + self.target_masked_tsdf.publish(masked_tsdf) + self._export_debug_tsdfs(self._latest_tsdf, masked_tsdf) + result = self._generate_grasps_from_tsdf(masked_tsdf) + if result is None: + return None + filtered = self._filter_candidates_to_bounds(result, bounds, cushion_m) + self.grasp_candidates.publish(filtered) + self.grasp_poses.publish(filtered.to_pose_array()) + return filtered + + def _on_tsdf(self, tsdf: TSDFGrid) -> None: + self._latest_tsdf = tsdf + if not self.config.auto_generate_on_tsdf: + return + if not self._should_auto_generate(tsdf.ts): + return + self._last_auto_generation_ts = tsdf.ts + try: + self.generate_grasps_from_tsdf(tsdf) + except RuntimeError as exc: + logger.warning("Automatic VGN generation failed: %s", exc) + empty = _make_candidate_array([], self.config.output_frame, tsdf.ts) + self.grasp_candidates.publish(empty) + + def _export_debug_tsdfs(self, full_tsdf: TSDFGrid, masked_tsdf: TSDFGrid) -> None: + if self.config.debug_export_dir is None: + return + ts = f"{time.time():.3f}".replace(".", "_") + try: + full_paths = export_tsdf_debug_files( + full_tsdf, self.config.debug_export_dir, f"{ts}_full_tsdf" + ) + masked_paths = export_tsdf_debug_files( + masked_tsdf, self.config.debug_export_dir, f"{ts}_target_masked_tsdf" + ) + except Exception as exc: + logger.warning("Failed to export TSDF debug files", error=str(exc)) + return + logger.info( + "Exported TSDF debug files", + full=[str(path) for path in full_paths], + masked=[str(path) for path in masked_paths], + ) + + def _should_auto_generate(self, ts: float) -> bool: + if self._last_auto_generation_ts is None: + return True + return ts - self._last_auto_generation_ts >= self.config.auto_generate_min_interval + + def _validate_tsdf(self, tsdf: TSDFGrid) -> None: + if tsdf.distances.shape != (1, 40, 40, 40): + raise ValueError(f"VGN expects TSDF shape (1, 40, 40, 40), got {tsdf.distances.shape}") + if tsdf.voxel_size <= 0: + raise ValueError("TSDF voxel_size must be positive") + + def _get_detector(self) -> _VGNDetector: + if self._detector is None: + model_path = self._resolve_model_path() + if importlib.util.find_spec("vgn") is None: + raise RuntimeError( + "VGN is not installed. Install the grasp optional dependencies " + "with `uv sync --extra grasp` before using VGNGraspGenModule." + ) + try: + from vgn.detection import VGN # type: ignore[import-not-found] + + self._detector = cast("_VGNDetector", VGN(model_path, rviz=False)) + except ModuleNotFoundError as exc: + if exc.name not in {"rospy", "sensor_msgs", "visualization_msgs"}: + raise RuntimeError( + "VGN is not installed. Install the grasp optional dependencies " + "with `uv sync --extra grasp` before using VGNGraspGenModule." + ) from exc + logger.warning( + "VGN ROS visualization dependency %s is unavailable; using headless VGN detector", + exc.name, + ) + self._detector = _HeadlessVGNDetector(model_path) + return self._detector + + def _resolve_model_path(self) -> str: + model_path = self.config.model_path or os.environ.get(self.config.model_path_env) + if not model_path: + raise RuntimeError( + f"VGN model path is required. Set `{self.config.model_path_env}` " + "or pass `model_path=` to VGNGraspGenModule.blueprint()." + ) + path = Path(model_path).expanduser() + if not path.exists(): + raise RuntimeError(f"VGN model file does not exist: {path}") + return str(path) + + def _convert_grasps( + self, + tsdf: TSDFGrid, + grasps: Sequence[object], + scores: Sequence[float], + ) -> GraspCandidateArray | None: + if len(grasps) == 0: + return _make_candidate_array([], self.config.output_frame, tsdf.ts) + + output_from_target = self._output_from_target_matrix(tsdf) + if output_from_target is None: + logger.warning( + "Cannot transform VGN grasps from %s to %s", + tsdf.frame_id, + self.config.output_frame, + ) + return None + + candidates: list[GraspCandidate] = [] + origin = np.array([tsdf.origin.x, tsdf.origin.y, tsdf.origin.z], dtype=np.float64) + for index, grasp in enumerate(grasps): + score = float(scores[index]) if index < len(scores) else 0.0 + if score < self.config.min_score: + continue + target_from_grasp = _matrix_from_vgn_grasp(grasp) + target_from_grasp[:3, 3] += origin + output_from_grasp = output_from_target @ target_from_grasp + candidates.append( + GraspCandidate( + pose=_pose_from_matrix(output_from_grasp), + jaw_width=_jaw_width(grasp, self.config.default_jaw_width), + score=score, + id=f"vgn-{index}", + ) + ) + + return _make_candidate_array(candidates, self.config.output_frame, tsdf.ts) + + def _output_from_target_matrix(self, tsdf: TSDFGrid) -> np.ndarray | None: + if tsdf.frame_id == self.config.output_frame: + return np.eye(4, dtype=np.float64) + transform = self.tf.get(self.config.output_frame, tsdf.frame_id, tsdf.ts, 0.1) + if transform is None: + return None + return transform.to_matrix().astype(np.float64) + + def _target_masked_tsdf( + self, + tsdf: TSDFGrid, + bounds: TargetBounds, + cushion_m: float, + ) -> tuple[TSDFGrid | None, TargetBounds | None]: + tsdf_from_bounds = self._frame_transform_matrix(tsdf.frame_id, bounds.frame_id, bounds.ts) + if tsdf_from_bounds is None: + logger.warning( + "Cannot transform target bounds from %s to %s", bounds.frame_id, tsdf.frame_id + ) + return None, None + + expanded = bounds.expanded(cushion_m) + min_corner, max_corner = _transformed_aabb(expanded, tsdf_from_bounds) + centers = _voxel_positions(tsdf) + inside = np.logical_and(centers >= min_corner, centers <= max_corner).all(axis=-1) + masked_distances = np.array(tsdf.distances, copy=True) + masked_distances[0, np.logical_not(inside)] = 1.0 + masked_weights = np.array(tsdf.weights, copy=True) if tsdf.weights is not None else None + if masked_weights is not None: + if masked_weights.ndim == 4: + masked_weights[0, np.logical_not(inside)] = 0.0 + else: + masked_weights[np.logical_not(inside)] = 0.0 + + masked_tsdf = TSDFGrid( + distances=masked_distances, + voxel_size=tsdf.voxel_size, + truncation_distance=tsdf.truncation_distance, + origin=tsdf.origin, + weights=masked_weights, + frame_id=tsdf.frame_id, + ts=tsdf.ts, + ) + bounds_center = (min_corner + max_corner) / 2.0 + bounds_size = max_corner - min_corner + return masked_tsdf, TargetBounds( + center=Vector3(bounds_center), + size=Vector3(bounds_size), + frame_id=tsdf.frame_id, + ts=tsdf.ts, + label=expanded.label, + ) + + def _frame_transform_matrix( + self, + parent_frame: str, + child_frame: str, + ts: float, + ) -> np.ndarray | None: + if parent_frame == child_frame: + return np.eye(4, dtype=np.float64) + transform = self.tf.get(parent_frame, child_frame, ts, 0.1) + if transform is None: + return None + return transform.to_matrix().astype(np.float64) + + def _filter_candidates_to_bounds( + self, + candidates: GraspCandidateArray, + bounds: TargetBounds, + cushion_m: float, + ) -> GraspCandidateArray: + output_from_bounds = self._frame_transform_matrix( + self.config.output_frame, + bounds.frame_id, + bounds.ts, + ) + if output_from_bounds is None: + return candidates + min_corner, max_corner = _transformed_aabb(bounds.expanded(cushion_m), output_from_bounds) + filtered = [ + candidate + for candidate in candidates + if _point_inside_bounds(candidate.pose.position, min_corner, max_corner) + ] + return _make_candidate_array(filtered, candidates.frame_id, candidates.ts) + + +class _HeadlessVGNDetector: + """Run pinned VGN inference without importing ROS-backed `vgn.vis`.""" + + def __init__(self, model_path: str) -> None: + import torch # type: ignore[import-not-found] + from vgn.networks import get_network # type: ignore[import-not-found] + + self._torch = torch + self._device = _select_torch_device(torch) + self._net = _load_vgn_network(torch, get_network, Path(model_path), self._device) + logger.info("Loaded headless VGN detector", device=str(self._device)) + + def __call__(self, state: _VGNState) -> tuple[Sequence[object], Sequence[float], float]: + from scipy import ndimage # type: ignore[import-not-found] + from vgn.grasp import Grasp, from_voxel_coordinates # type: ignore[import-not-found] + from vgn.utils.transform import Rotation, Transform # type: ignore[import-not-found] + + tsdf_volume = state.tsdf.get_grid() + voxel_size = state.tsdf.voxel_size + started_at = time.time() + + quality_volume, rotation_volume, width_volume = self._predict(tsdf_volume) + quality_volume = ndimage.gaussian_filter(quality_volume, sigma=1.0, mode="nearest") + + squeezed_tsdf = tsdf_volume.squeeze() + outside_voxels = squeezed_tsdf > 0.5 + inside_voxels = np.logical_and(1e-3 < squeezed_tsdf, squeezed_tsdf < 0.5) + valid_voxels = ndimage.binary_dilation( + outside_voxels, + iterations=2, + mask=np.logical_not(inside_voxels), + ) + quality_volume[np.logical_not(valid_voxels)] = 0.0 + quality_volume[np.logical_or(width_volume < 1.33, width_volume > 9.33)] = 0.0 + + quality_volume[quality_volume < 0.90] = 0.0 + max_volume = ndimage.maximum_filter(quality_volume, size=4) + quality_volume = np.where(quality_volume == max_volume, quality_volume, 0.0) + + grasps: list[object] = [] + scores: list[float] = [] + for index in np.argwhere(np.where(quality_volume, 1.0, 0.0)): + i, j, k = index + score = float(quality_volume[i, j, k]) + rotation = Rotation.from_quat(rotation_volume[:, i, j, k]) + translation = np.array([i, j, k], dtype=np.float64) + grasp = Grasp(Transform(rotation, translation), float(width_volume[i, j, k])) + grasps.append(from_voxel_coordinates(grasp, voxel_size)) + scores.append(score) + + return grasps, scores, time.time() - started_at + + def _predict(self, tsdf_volume: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + if tsdf_volume.shape != (1, 40, 40, 40): + raise ValueError(f"VGN expects TSDF shape (1, 40, 40, 40), got {tsdf_volume.shape}") + + tsdf_tensor = self._torch.from_numpy(tsdf_volume).unsqueeze(0).to(self._device) + with self._torch.no_grad(): + quality, rotation, width = cast("_TorchNetwork", self._net)(tsdf_tensor) + quality_tensor = cast("_TorchTensor", quality) + rotation_tensor = cast("_TorchTensor", rotation) + width_tensor = cast("_TorchTensor", width) + return ( + quality_tensor.cpu().squeeze().numpy(), + rotation_tensor.cpu().squeeze().numpy(), + width_tensor.cpu().squeeze().numpy(), + ) + + +def _matrix_from_vgn_grasp(grasp: object) -> np.ndarray: + pose = getattr(grasp, "pose", grasp) + if isinstance(pose, np.ndarray): + return _validate_matrix(pose) + as_matrix = getattr(pose, "as_matrix", None) + if callable(as_matrix): + return _validate_matrix(np.asarray(as_matrix(), dtype=np.float64)) + + rotation = getattr(pose, "rotation", None) + translation = getattr(pose, "translation", None) + if rotation is None or translation is None: + raise ValueError(f"Unsupported VGN grasp pose type: {type(pose).__name__}") + + rotation_matrix = _rotation_matrix(rotation) + translation_vector = _translation_vector(translation) + matrix = np.eye(4, dtype=np.float64) + matrix[:3, :3] = rotation_matrix + matrix[:3, 3] = translation_vector + return matrix + + +def _validate_matrix(matrix: np.ndarray) -> np.ndarray: + if matrix.shape != (4, 4): + raise ValueError(f"VGN grasp matrix must be 4x4, got {matrix.shape}") + return matrix.astype(np.float64) + + +def _rotation_matrix(rotation: object) -> np.ndarray: + as_matrix = getattr(rotation, "as_matrix", None) + if callable(as_matrix): + matrix = np.asarray(as_matrix(), dtype=np.float64) + else: + matrix = np.asarray(rotation, dtype=np.float64) + if matrix.shape != (3, 3): + raise ValueError(f"VGN grasp rotation must be 3x3, got {matrix.shape}") + return matrix + + +def _translation_vector(translation: object) -> np.ndarray: + vector = np.asarray(translation, dtype=np.float64) + if vector.shape != (3,): + raise ValueError(f"VGN grasp translation must have shape (3,), got {vector.shape}") + return vector + + +def _pose_from_matrix(matrix: np.ndarray) -> Pose: + pose = Pose() # type: ignore[call-arg] + pose.position = Vector3(matrix[:3, 3]) + pose.orientation = Quaternion.from_rotation_matrix(matrix[:3, :3]) + return pose + + +def _make_candidate_array( + candidates: list[GraspCandidate], + frame_id: str, + ts: float | None = None, +) -> GraspCandidateArray: + timestamp = ts if ts is not None else time.time() + header = Header(frame_id) + sec = int(timestamp) + header.stamp.sec = sec + header.stamp.nsec = int((timestamp - sec) * 1_000_000_000) + header.frame_id = frame_id + return GraspCandidateArray( + header=header, + candidates=candidates, + ) + + +def _voxel_positions(tsdf: TSDFGrid) -> np.ndarray: + x, y, z = tsdf.resolution + indices = np.indices((x, y, z), dtype=np.float64) + origin = np.array([tsdf.origin.x, tsdf.origin.y, tsdf.origin.z], dtype=np.float64) + return np.moveaxis(indices, 0, -1) * tsdf.voxel_size + origin + + +def _transformed_aabb(bounds: TargetBounds, transform: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + center = np.array([bounds.center.x, bounds.center.y, bounds.center.z], dtype=np.float64) + half = np.array([bounds.size.x, bounds.size.y, bounds.size.z], dtype=np.float64) / 2.0 + corners = np.array( + [ + [center[0] + sx * half[0], center[1] + sy * half[1], center[2] + sz * half[2], 1.0] + for sx in (-1.0, 1.0) + for sy in (-1.0, 1.0) + for sz in (-1.0, 1.0) + ], + dtype=np.float64, + ) + transformed = (transform @ corners.T).T[:, :3] + return transformed.min(axis=0), transformed.max(axis=0) + + +def _point_inside_bounds(point: Vector3, min_corner: np.ndarray, max_corner: np.ndarray) -> bool: + values = np.array([point.x, point.y, point.z], dtype=np.float64) + return bool(np.logical_and(values >= min_corner, values <= max_corner).all()) + + +def _select_torch_device(torch: object) -> object: + cuda = torch.cuda # type: ignore[attr-defined] + if not cuda.is_available() or cuda.device_count() <= 0: + return torch.device("cpu") # type: ignore[attr-defined] + try: + torch.empty(1, device="cuda:0") # type: ignore[attr-defined] + cuda.synchronize() + except Exception as exc: + logger.warning( + "CUDA is reported available but failed a smoke allocation; using CPU", error=str(exc) + ) + return torch.device("cpu") # type: ignore[attr-defined] + return torch.device("cuda:0") # type: ignore[attr-defined] + + +def _load_vgn_network( + torch: object, + get_network: object, + model_path: Path, + device: object, +) -> object: + model_name = model_path.stem.split("_")[1] + net = get_network(model_name) # type: ignore[operator] + state_dict = torch.load(model_path, map_location="cpu") # type: ignore[attr-defined] + net.load_state_dict(state_dict) + net = net.to(device) + net.eval() + return net + + +def _jaw_width(grasp: object, default_width: float) -> float: + width = getattr(grasp, "width", default_width) + if isinstance(width, int | float | np.floating): + return float(width) + return float(default_width) diff --git a/dimos/msgs/grasping_msgs/GraspCandidate.py b/dimos/msgs/grasping_msgs/GraspCandidate.py new file mode 100644 index 0000000000..608b9fcb02 --- /dev/null +++ b/dimos/msgs/grasping_msgs/GraspCandidate.py @@ -0,0 +1,89 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 + + +@dataclass(slots=True) +class GraspCandidate: + """A scored gripper pose candidate.""" + + pose: Pose + jaw_width: float + score: float + id: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "pose": { + "position": [self.pose.position.x, self.pose.position.y, self.pose.position.z], + "orientation": [ + self.pose.orientation.x, + self.pose.orientation.y, + self.pose.orientation.z, + self.pose.orientation.w, + ], + }, + "jaw_width": self.jaw_width, + "score": self.score, + "id": self.id, + } + + @classmethod + def from_dict(cls, value: object) -> GraspCandidate: + if not isinstance(value, dict): + raise ValueError("GraspCandidate must decode from a JSON object") + payload = cast("dict[str, object]", value) + pose_payload = _as_dict(payload["pose"]) + position = _as_float_list(pose_payload["position"], expected_len=3) + orientation = _as_float_list(pose_payload["orientation"], expected_len=4) + id_value = payload.get("id") + return cls( + pose=_make_pose(position, orientation), + jaw_width=_as_float(payload["jaw_width"]), + score=_as_float(payload["score"]), + id=str(id_value) if id_value is not None else None, + ) + + +def _make_pose(position: list[float], orientation: list[float]) -> Pose: + pose = Pose() # type: ignore[call-arg] + pose.position = Vector3(position) + pose.orientation = Quaternion(orientation) + return pose + + +def _as_dict(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError("Expected JSON object") + return cast("dict[str, object]", value) + + +def _as_float_list(value: object, *, expected_len: int) -> list[float]: + if not isinstance(value, list) or len(value) != expected_len: + raise ValueError(f"Expected a list of {expected_len} floats") + return [float(item) for item in value] + + +def _as_float(value: object) -> float: + if not isinstance(value, int | float | str): + raise ValueError(f"Expected a float-compatible value, got {type(value).__name__}") + return float(value) diff --git a/dimos/msgs/grasping_msgs/GraspCandidateArray.py b/dimos/msgs/grasping_msgs/GraspCandidateArray.py new file mode 100644 index 0000000000..209b7b1a70 --- /dev/null +++ b/dimos/msgs/grasping_msgs/GraspCandidateArray.py @@ -0,0 +1,185 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +import json +import time +from typing import BinaryIO, cast + +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseArray import PoseArray +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.GraspCandidate import GraspCandidate +from dimos.msgs.std_msgs.Header import Header +from dimos.types.timestamped import Timestamped + + +@dataclass(frozen=True, slots=True) +class GraspVisConfig: + """Rerun wireframe defaults for parallel-jaw grasp candidates.""" + + max_grasps: int = 50 + top_k_highlight: int = 5 + finger_length_m: float = 0.055 + palm_depth_m: float = 0.035 + finger_thickness_m: float = 0.004 + default_width_m: float = 0.08 + min_score: float = 0.0 + top_color: tuple[int, int, int] = (0, 255, 80) + good_color: tuple[int, int, int] = (255, 220, 0) + low_color: tuple[int, int, int] = (255, 80, 0) + + +class GraspCandidateArray(Timestamped): + """An ordered array of scored grasp candidates.""" + + msg_name = "grasping_msgs.GraspCandidateArray" + + def __init__( + self, + header: Header | None = None, + candidates: list[GraspCandidate] | None = None, + ) -> None: + self.header = header if header is not None else _make_header(time.time(), "world") + self.ts = self.header.timestamp + self.candidates = candidates if candidates is not None else [] + + @property + def frame_id(self) -> str: + return str(self.header.frame_id) + + def __len__(self) -> int: + return len(self.candidates) + + def __iter__(self) -> Iterator[GraspCandidate]: + return iter(self.candidates) + + def __getitem__(self, index: int) -> GraspCandidate: + return self.candidates[index] + + def to_pose_array(self) -> PoseArray: + """Return PoseArray compatibility view preserving candidate order.""" + return PoseArray( + header=self.header, poses=[candidate.pose for candidate in self.candidates] + ) + + def lcm_encode(self) -> bytes: + payload = { + "header": {"ts": self.header.timestamp, "frame_id": self.header.frame_id}, + "candidates": [candidate.to_dict() for candidate in self.candidates], + } + return json.dumps(payload, separators=(",", ":")).encode("utf-8") + + encode = lcm_encode + + @classmethod + def lcm_decode(cls, data: bytes | BinaryIO) -> GraspCandidateArray: + raw = _read_bytes(data) + payload = _as_dict(json.loads(raw.decode("utf-8"))) + header_payload = _as_dict(payload["header"]) + candidates_payload = _as_list(payload["candidates"]) + return cls( + header=_make_header(_as_float(header_payload["ts"]), str(header_payload["frame_id"])), + candidates=[GraspCandidate.from_dict(candidate) for candidate in candidates_payload], + ) + + decode = lcm_decode + + def to_rerun(self, config: GraspVisConfig | None = None) -> object: + """Render candidates as simplified gripper wireframes.""" + import rerun as rr # type: ignore[import-not-found] + + cfg = config if config is not None else GraspVisConfig() + visible = [candidate for candidate in self.candidates if candidate.score >= cfg.min_score] + visible.sort(key=lambda candidate: candidate.score, reverse=True) + visible = visible[: cfg.max_grasps] + if not visible: + return rr.LineStrips3D([]) + + strips: list[list[list[float]]] = [] + colors: list[tuple[int, int, int]] = [] + radii: list[float] = [] + for rank, candidate in enumerate(visible): + color = ( + cfg.top_color if rank < cfg.top_k_highlight else _score_color(candidate.score, cfg) + ) + width = candidate.jaw_width if candidate.jaw_width > 0.0 else cfg.default_width_m + for strip in _candidate_wireframe(candidate.pose, width, cfg): + strips.append(strip) + colors.append(color) + radii.append(cfg.finger_thickness_m) + + return rr.LineStrips3D(strips=strips, colors=colors, radii=radii) + + +def _candidate_wireframe( + pose: Pose, width: float, config: GraspVisConfig +) -> list[list[list[float]]]: + half_width = width / 2.0 + local_strips = [ + [Vector3(0.0, -half_width, 0.0), Vector3(config.finger_length_m, -half_width, 0.0)], + [Vector3(0.0, half_width, 0.0), Vector3(config.finger_length_m, half_width, 0.0)], + [Vector3(0.0, -half_width, 0.0), Vector3(0.0, half_width, 0.0)], + [Vector3(-config.palm_depth_m, -half_width, 0.0), Vector3(0.0, -half_width, 0.0)], + [Vector3(-config.palm_depth_m, half_width, 0.0), Vector3(0.0, half_width, 0.0)], + ] + return [[_transform_point(pose, point) for point in strip] for strip in local_strips] + + +def _transform_point(pose: Pose, point: Vector3) -> list[float]: + rotated = pose.orientation.rotate_vector(point) + world = pose.position + rotated + return [world.x, world.y, world.z] + + +def _score_color(score: float, config: GraspVisConfig) -> tuple[int, int, int]: + if score >= 0.5: + return config.good_color + return config.low_color + + +def _make_header(ts: float, frame_id: str) -> Header: + header = Header(frame_id) + sec = int(ts) + header.stamp.sec = sec + header.stamp.nsec = int((ts - sec) * 1_000_000_000) + header.frame_id = frame_id + return header + + +def _read_bytes(data: bytes | BinaryIO) -> bytes: + if isinstance(data, bytes): + return data + return data.read() + + +def _as_dict(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError("Expected JSON object") + return cast("dict[str, object]", value) + + +def _as_list(value: object) -> list[object]: + if not isinstance(value, list): + raise ValueError("Expected JSON list") + return value + + +def _as_float(value: object) -> float: + if not isinstance(value, int | float | str): + raise ValueError(f"Expected a float-compatible value, got {type(value).__name__}") + return float(value) diff --git a/dimos/msgs/grasping_msgs/TargetBounds.py b/dimos/msgs/grasping_msgs/TargetBounds.py new file mode 100644 index 0000000000..4dceae44ff --- /dev/null +++ b/dimos/msgs/grasping_msgs/TargetBounds.py @@ -0,0 +1,108 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +import json +import time +from typing import BinaryIO, cast + +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.types.timestamped import Timestamped + + +@dataclass(slots=True) +class TargetBounds(Timestamped): + """Frame-axis-aligned bounds for a grasp target.""" + + center: Vector3 + size: Vector3 + frame_id: str = "world" + ts: float = 0.0 + label: str = "target" + + msg_name = "grasping_msgs.TargetBounds" + + def __post_init__(self) -> None: + if self.ts == 0.0: + self.ts = time.time() + + def expanded(self, cushion_m: float) -> TargetBounds: + cushion = max(0.0, float(cushion_m)) * 2.0 + return TargetBounds( + center=self.center, + size=Vector3(self.size.x + cushion, self.size.y + cushion, self.size.z + cushion), + frame_id=self.frame_id, + ts=self.ts, + label=self.label, + ) + + def lcm_encode(self) -> bytes: + return json.dumps( + { + "center": [self.center.x, self.center.y, self.center.z], + "size": [self.size.x, self.size.y, self.size.z], + "frame_id": self.frame_id, + "ts": self.ts, + "label": self.label, + }, + separators=(",", ":"), + ).encode("utf-8") + + encode = lcm_encode + + @classmethod + def lcm_decode(cls, data: bytes | BinaryIO) -> TargetBounds: + raw = data if isinstance(data, bytes) else data.read() + payload = _as_dict(json.loads(raw.decode("utf-8"))) + center = _as_float_list(payload["center"]) + size = _as_float_list(payload["size"]) + return cls( + center=Vector3(center), + size=Vector3(size), + frame_id=str(payload["frame_id"]), + ts=_as_float(payload["ts"]), + label=str(payload.get("label", "target")), + ) + + decode = lcm_decode + + def to_rerun(self) -> object: + import rerun as rr # type: ignore[import-not-found] + + return rr.Boxes3D( + centers=[[self.center.x, self.center.y, self.center.z]], + sizes=[[self.size.x, self.size.y, self.size.z]], + colors=[[0, 200, 255]], + labels=[self.label], + ) + + +def _as_dict(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError("TargetBounds payload must be a JSON object") + return cast("dict[str, object]", value) + + +def _as_float_list(value: object) -> list[float]: + if not isinstance(value, list) or len(value) != 3: + raise ValueError("Expected a 3-element numeric list") + return [float(item) for item in value] + + +def _as_float(value: object) -> float: + if not isinstance(value, int | float | str): + raise ValueError(f"Expected a float-compatible value, got {type(value).__name__}") + return float(value) diff --git a/dimos/msgs/grasping_msgs/test_GraspCandidateArray.py b/dimos/msgs/grasping_msgs/test_GraspCandidateArray.py new file mode 100644 index 0000000000..e05e74b338 --- /dev/null +++ b/dimos/msgs/grasping_msgs/test_GraspCandidateArray.py @@ -0,0 +1,97 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import cast + +import pytest +import rerun as rr + +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.GraspCandidate import GraspCandidate +from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray +from dimos.msgs.std_msgs.Header import Header + + +def _candidate( + candidate_id: str, + x: float, + score: float, + jaw_width: float, +) -> GraspCandidate: + return GraspCandidate( + pose=Pose(Vector3(x, x + 1.0, x + 2.0), Quaternion(0.0, 0.0, 0.0, 1.0)), + jaw_width=jaw_width, + score=score, + id=candidate_id, + ) + + +def test_to_pose_array_preserves_candidate_order() -> None: + candidates = [_candidate("first", 1.0, 0.4, 0.05), _candidate("second", 2.0, 0.9, 0.06)] + msg = GraspCandidateArray(header=Header(12.5, "world"), candidates=candidates) + + pose_array = msg.to_pose_array() + + assert pose_array.header.frame_id == "world" + assert [pose.position.x for pose in pose_array.poses] == [1.0, 2.0] + assert pose_array.poses[0] is candidates[0].pose + assert pose_array.poses[1] is candidates[1].pose + + +def test_grasp_candidate_array_lcm_round_trip_preserves_header_and_candidates() -> None: + msg = GraspCandidateArray( + header=Header(123.25, "map"), + candidates=[_candidate("grasp-a", 0.1, 0.85, 0.04), _candidate("grasp-b", 0.2, 0.35, 0.07)], + ) + + decoded = GraspCandidateArray.lcm_decode(msg.lcm_encode()) + + assert decoded.header.frame_id == "map" + assert decoded.header.timestamp == pytest.approx(123.25) + assert decoded.ts == pytest.approx(123.25) + assert [candidate.id for candidate in decoded] == ["grasp-a", "grasp-b"] + assert [candidate.score for candidate in decoded] == pytest.approx([0.85, 0.35]) + assert [candidate.jaw_width for candidate in decoded] == pytest.approx([0.04, 0.07]) + assert [ + (candidate.pose.position.x, candidate.pose.position.y, candidate.pose.position.z) + for candidate in decoded + ] == pytest.approx([(0.1, 1.1, 2.1), (0.2, 1.2, 2.2)]) + + +def test_grasp_candidate_array_to_rerun_empty_array_is_safe() -> None: + msg = GraspCandidateArray(header=Header(12.5, "world"), candidates=[]) + + strips = msg.to_rerun() + + assert isinstance(strips, rr.LineStrips3D) + strips = cast("rr.LineStrips3D", strips) + assert strips.strips.as_arrow_array().to_pylist() == [] + + +def test_grasp_candidate_array_to_rerun_non_empty_exposes_multiple_strips() -> None: + msg = GraspCandidateArray( + header=Header(12.5, "world"), + candidates=[_candidate("high", 0.0, 0.9, 0.08), _candidate("low", 1.0, 0.2, 0.06)], + ) + + strips = msg.to_rerun() + + assert isinstance(strips, rr.LineStrips3D) + strips = cast("rr.LineStrips3D", strips) + strip_values = strips.strips.as_arrow_array().to_pylist() + assert len(strip_values) == 10 + assert len(strip_values[0]) == 2 + assert len(strips.colors.as_arrow_array().to_pylist()) == 10 diff --git a/dimos/msgs/grasping_msgs/test_TargetBounds.py b/dimos/msgs/grasping_msgs/test_TargetBounds.py new file mode 100644 index 0000000000..9e8692866f --- /dev/null +++ b/dimos/msgs/grasping_msgs/test_TargetBounds.py @@ -0,0 +1,56 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import cast + +import pytest +import rerun as rr + +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.TargetBounds import TargetBounds + + +def test_target_bounds_lcm_round_trip_preserves_bounds() -> None: + msg = TargetBounds( + center=Vector3(1.0, 2.0, 3.0), + size=Vector3(0.1, 0.2, 0.3), + frame_id="map", + ts=123.5, + label="target mug", + ) + + decoded = TargetBounds.lcm_decode(msg.lcm_encode()) + + assert decoded.frame_id == "map" + assert decoded.ts == pytest.approx(123.5) + assert decoded.label == "target mug" + assert (decoded.center.x, decoded.center.y, decoded.center.z) == pytest.approx((1.0, 2.0, 3.0)) + assert (decoded.size.x, decoded.size.y, decoded.size.z) == pytest.approx((0.1, 0.2, 0.3)) + + +def test_target_bounds_to_rerun_exposes_box_archetype() -> None: + msg = TargetBounds( + center=Vector3(1.0, 2.0, 3.0), + size=Vector3(0.1, 0.2, 0.3), + ts=123.5, + label="target mug", + ) + + boxes = msg.to_rerun() + + assert isinstance(boxes, rr.Boxes3D) + boxes = cast("rr.Boxes3D", boxes) + assert boxes.centers.as_arrow_array().to_pylist() == [[1.0, 2.0, 3.0]] + assert boxes.half_sizes.as_arrow_array().to_pylist()[0] == pytest.approx([0.05, 0.1, 0.15]) + assert boxes.labels.as_arrow_array().to_pylist() == ["target mug"] diff --git a/dimos/msgs/perception_msgs/RegisteredObject.py b/dimos/msgs/perception_msgs/RegisteredObject.py new file mode 100644 index 0000000000..0a34d0116d --- /dev/null +++ b/dimos/msgs/perception_msgs/RegisteredObject.py @@ -0,0 +1,102 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +import json +import time +from typing import BinaryIO, cast + +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.types.timestamped import Timestamped + + +@dataclass(slots=True) +class RegisteredObject(Timestamped): + """Runtime-registered object with frame-axis-aligned target bounds.""" + + object_id: str + name: str + center: Vector3 + size: Vector3 + frame_id: str = "world" + ts: float = 0.0 + + msg_name = "perception_msgs.RegisteredObject" + + def __post_init__(self) -> None: + if self.ts == 0.0: + self.ts = time.time() + + def lcm_encode(self) -> bytes: + return json.dumps( + { + "object_id": self.object_id, + "name": self.name, + "center": [self.center.x, self.center.y, self.center.z], + "size": [self.size.x, self.size.y, self.size.z], + "frame_id": self.frame_id, + "ts": self.ts, + }, + separators=(",", ":"), + ).encode("utf-8") + + encode = lcm_encode + + @classmethod + def lcm_decode(cls, data: bytes | BinaryIO) -> RegisteredObject: + raw = data if isinstance(data, bytes) else data.read() + payload = _as_dict(json.loads(raw.decode("utf-8"))) + center = _as_float_list(payload["center"]) + size = _as_float_list(payload["size"]) + return cls( + object_id=str(payload["object_id"]), + name=str(payload["name"]), + center=Vector3(center), + size=Vector3(size), + frame_id=str(payload["frame_id"]), + ts=_as_float(payload["ts"]), + ) + + decode = lcm_decode + + def to_rerun(self) -> object: + """Render target bounds as a Rerun box.""" + import rerun as rr # type: ignore[import-not-found] + + return rr.Boxes3D( + centers=[[self.center.x, self.center.y, self.center.z]], + sizes=[[self.size.x, self.size.y, self.size.z]], + colors=[[0, 200, 255]], + labels=[f"{self.name}:{self.object_id}"], + ) + + +def _as_dict(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError("RegisteredObject payload must be a JSON object") + return cast("dict[str, object]", value) + + +def _as_float_list(value: object) -> list[float]: + if not isinstance(value, list) or len(value) != 3: + raise ValueError("Expected a 3-element numeric list") + return [float(item) for item in value] + + +def _as_float(value: object) -> float: + if not isinstance(value, int | float | str): + raise ValueError(f"Expected a float-compatible value, got {type(value).__name__}") + return float(value) diff --git a/dimos/msgs/perception_msgs/test_RegisteredObject.py b/dimos/msgs/perception_msgs/test_RegisteredObject.py new file mode 100644 index 0000000000..30a61736eb --- /dev/null +++ b/dimos/msgs/perception_msgs/test_RegisteredObject.py @@ -0,0 +1,59 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import cast + +import pytest +import rerun as rr + +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject + + +def test_registered_object_lcm_round_trip_preserves_bounds() -> None: + msg = RegisteredObject( + object_id="obj-7", + name="mug", + center=Vector3(1.0, 2.0, 3.0), + size=Vector3(0.1, 0.2, 0.3), + frame_id="map", + ts=123.5, + ) + + decoded = RegisteredObject.lcm_decode(msg.lcm_encode()) + + assert decoded.object_id == "obj-7" + assert decoded.name == "mug" + assert decoded.frame_id == "map" + assert decoded.ts == pytest.approx(123.5) + assert (decoded.center.x, decoded.center.y, decoded.center.z) == pytest.approx((1.0, 2.0, 3.0)) + assert (decoded.size.x, decoded.size.y, decoded.size.z) == pytest.approx((0.1, 0.2, 0.3)) + + +def test_registered_object_to_rerun_exposes_box_archetype() -> None: + msg = RegisteredObject( + object_id="obj-7", + name="mug", + center=Vector3(1.0, 2.0, 3.0), + size=Vector3(0.1, 0.2, 0.3), + ts=123.5, + ) + + boxes = msg.to_rerun() + + assert isinstance(boxes, rr.Boxes3D) + boxes = cast("rr.Boxes3D", boxes) + assert boxes.centers.as_arrow_array().to_pylist() == [[1.0, 2.0, 3.0]] + assert boxes.half_sizes.as_arrow_array().to_pylist()[0] == pytest.approx([0.05, 0.1, 0.15]) + assert boxes.labels.as_arrow_array().to_pylist() == ["mug:obj-7"] diff --git a/dimos/msgs/reconstruction_msgs/ReconstructionStatus.py b/dimos/msgs/reconstruction_msgs/ReconstructionStatus.py new file mode 100644 index 0000000000..da404175a7 --- /dev/null +++ b/dimos/msgs/reconstruction_msgs/ReconstructionStatus.py @@ -0,0 +1,130 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +import time +from typing import BinaryIO, cast + +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.types.timestamped import Timestamped + + +class ReconstructionStatus(Timestamped): + """Status for a streaming scene reconstruction module.""" + + msg_name = "reconstruction_msgs.ReconstructionStatus" + + def __init__( + self, + *, + integrated_frames: int = 0, + dropped_frames: int = 0, + last_error: str = "", + active: bool = True, + paused: bool = False, + latest_integration_ts: float | None = None, + workspace_origin: Vector3 | None = None, + workspace_size: float = 0.3, + frame_id: str = "world", + ts: float | None = None, + ) -> None: + self.integrated_frames = int(integrated_frames) + self.dropped_frames = int(dropped_frames) + self.last_error = last_error + self.active = active + self.paused = paused + self.latest_integration_ts = latest_integration_ts + self.workspace_origin = workspace_origin if workspace_origin is not None else Vector3() + self.workspace_size = float(workspace_size) + self.frame_id = frame_id + self.ts = ts if ts is not None else time.time() + + def lcm_encode(self) -> bytes: + payload = { + "integrated_frames": self.integrated_frames, + "dropped_frames": self.dropped_frames, + "last_error": self.last_error, + "active": self.active, + "paused": self.paused, + "latest_integration_ts": self.latest_integration_ts, + "workspace_origin": [ + self.workspace_origin.x, + self.workspace_origin.y, + self.workspace_origin.z, + ], + "workspace_size": self.workspace_size, + "frame_id": self.frame_id, + "ts": self.ts, + } + return json.dumps(payload, separators=(",", ":")).encode("utf-8") + + encode = lcm_encode + + @classmethod + def lcm_decode(cls, data: bytes | BinaryIO) -> ReconstructionStatus: + raw = _read_bytes(data) + payload = _as_payload(json.loads(raw.decode("utf-8"))) + origin = _as_float_list(payload["workspace_origin"], expected_len=3) + return cls( + integrated_frames=_as_int(payload["integrated_frames"]), + dropped_frames=_as_int(payload["dropped_frames"]), + last_error=str(payload["last_error"]), + active=bool(payload.get("active", True)), + paused=bool(payload["paused"]), + latest_integration_ts=_as_optional_float(payload.get("latest_integration_ts")), + workspace_origin=Vector3(origin), + workspace_size=_as_float(payload["workspace_size"]), + frame_id=str(payload["frame_id"]), + ts=_as_float(payload["ts"]), + ) + + decode = lcm_decode + + +def _as_payload(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError("ReconstructionStatus payload must be a JSON object") + return cast("dict[str, object]", value) + + +def _read_bytes(data: bytes | BinaryIO) -> bytes: + if isinstance(data, bytes): + return data + return data.read() + + +def _as_int(value: object) -> int: + if not isinstance(value, int | float | str): + raise ValueError(f"Expected an int-compatible value, got {type(value).__name__}") + return int(value) + + +def _as_float(value: object) -> float: + if not isinstance(value, int | float | str): + raise ValueError(f"Expected a float-compatible value, got {type(value).__name__}") + return float(value) + + +def _as_optional_float(value: object) -> float | None: + if value is None: + return None + return _as_float(value) + + +def _as_float_list(value: object, *, expected_len: int) -> list[float]: + if not isinstance(value, list) or len(value) != expected_len: + raise ValueError(f"Expected a list of {expected_len} floats") + return [float(item) for item in value] diff --git a/dimos/msgs/reconstruction_msgs/TSDFGrid.py b/dimos/msgs/reconstruction_msgs/TSDFGrid.py new file mode 100644 index 0000000000..960d299117 --- /dev/null +++ b/dimos/msgs/reconstruction_msgs/TSDFGrid.py @@ -0,0 +1,197 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from io import BytesIO +import json +import time +from typing import BinaryIO, cast + +import numpy as np + +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.types.timestamped import Timestamped + + +class TSDFGrid(Timestamped): + """Dense TSDF grid with min-corner origin semantics. + + ``distances`` is always shaped ``(1, X, Y, Z)``. Voxel ``[0, 0, 0]`` is at + ``origin`` in ``frame_id``. The metric center of voxel ``[i, j, k]`` is + ``origin + [i, j, k] * voxel_size``. + """ + + msg_name = "reconstruction_msgs.TSDFGrid" + + def __init__( + self, + distances: np.ndarray, + voxel_size: float, + truncation_distance: float, + origin: Vector3 | None = None, + weights: np.ndarray | None = None, + frame_id: str = "world", + ts: float | None = None, + ) -> None: + self.distances = _validate_distances(distances) + self.voxel_size = float(voxel_size) + self.truncation_distance = float(truncation_distance) + self.origin = origin if origin is not None else Vector3() + self.weights = ( + _validate_weights(weights, self.distances.shape) if weights is not None else None + ) + self.frame_id = frame_id + self.ts = ts if ts is not None else time.time() + + @property + def resolution(self) -> tuple[int, int, int]: + shape = self.distances.shape + return (shape[1], shape[2], shape[3]) + + @property + def size(self) -> tuple[float, float, float]: + x, y, z = self.resolution + return (x * self.voxel_size, y * self.voxel_size, z * self.voxel_size) + + def voxel_position(self, i: int, j: int, k: int) -> Vector3: + """Return the metric min-corner position for a voxel index.""" + return Vector3( + self.origin.x + i * self.voxel_size, + self.origin.y + j * self.voxel_size, + self.origin.z + k * self.voxel_size, + ) + + def lcm_encode(self) -> bytes: + """Encode as a compact NumPy container for LCM transport.""" + metadata = { + "voxel_size": self.voxel_size, + "truncation_distance": self.truncation_distance, + "origin": [self.origin.x, self.origin.y, self.origin.z], + "frame_id": self.frame_id, + "ts": self.ts, + } + out = BytesIO() + if self.weights is not None: + np.savez_compressed( + out, + distances=self.distances, + metadata=np.asarray(json.dumps(metadata), dtype=np.bytes_), + weights=self.weights, + ) + else: + np.savez_compressed( + out, + distances=self.distances, + metadata=np.asarray(json.dumps(metadata), dtype=np.bytes_), + ) + return out.getvalue() + + encode = lcm_encode + + @classmethod + def lcm_decode(cls, data: bytes | BinaryIO) -> TSDFGrid: + raw = _read_bytes(data) + with np.load(BytesIO(raw), allow_pickle=False) as archive: + metadata_raw = archive["metadata"].item() + metadata_text = ( + metadata_raw.decode("utf-8") + if isinstance(metadata_raw, bytes) + else str(metadata_raw) + ) + metadata = _as_metadata(json.loads(metadata_text)) + weights = archive["weights"] if "weights" in archive.files else None + origin_values = _as_float_list(metadata["origin"], expected_len=3) + return cls( + distances=archive["distances"], + voxel_size=_as_float(metadata["voxel_size"]), + truncation_distance=_as_float(metadata["truncation_distance"]), + origin=Vector3(origin_values), + weights=weights, + frame_id=str(metadata["frame_id"]), + ts=_as_float(metadata["ts"]), + ) + + decode = lcm_decode + + def to_rerun(self, max_points: int = 25_000) -> object: + """Visualize near-surface TSDF voxels as Rerun points.""" + import rerun as rr # type: ignore[import-not-found] + + field = self.distances[0] + surface_mask = np.abs(field) <= self.voxel_size + if self.weights is not None: + weights = self.weights[0] if self.weights.ndim == 4 else self.weights + surface_mask = np.logical_and(surface_mask, weights > 0.0) + surface = np.argwhere(surface_mask) + if len(surface) == 0: + return rr.Points3D([]) + if len(surface) > max_points: + stride = int(np.ceil(len(surface) / max_points)) + surface = surface[::stride] + + origin = np.array([self.origin.x, self.origin.y, self.origin.z], dtype=np.float32) + points = origin + surface.astype(np.float32) * np.float32(self.voxel_size) + values = np.clip(np.abs(field[tuple(surface.T)]) / self.truncation_distance, 0.0, 1.0) + colors = np.stack( + [ + (255.0 * (1.0 - values)).astype(np.uint8), + (180.0 * values).astype(np.uint8), + np.full_like(values, 255, dtype=np.uint8), + ], + axis=1, + ) + return rr.Points3D(points, colors=colors, radii=self.voxel_size * 0.5) + + +def _validate_distances(distances: np.ndarray) -> np.ndarray: + arr = np.asarray(distances, dtype=np.float32) + if arr.ndim != 4 or arr.shape[0] != 1: + raise ValueError(f"TSDF distances must have shape (1, X, Y, Z), got {arr.shape}") + return np.ascontiguousarray(arr) + + +def _validate_weights(weights: np.ndarray, distances_shape: tuple[int, ...]) -> np.ndarray: + arr = np.asarray(weights, dtype=np.float32) + if arr.shape == distances_shape[1:]: + return np.ascontiguousarray(arr) + if arr.shape == distances_shape: + return np.ascontiguousarray(arr) + raise ValueError( + f"TSDF weights must have shape {distances_shape[1:]} or {distances_shape}, got {arr.shape}" + ) + + +def _read_bytes(data: bytes | BinaryIO) -> bytes: + if isinstance(data, bytes): + return data + return data.read() + + +def _as_metadata(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError("TSDF metadata must decode to a JSON object") + return cast("dict[str, object]", value) + + +def _as_float(value: object) -> float: + if not isinstance(value, int | float | str): + raise ValueError(f"Expected a float-compatible value, got {type(value).__name__}") + return float(value) + + +def _as_float_list(value: object, *, expected_len: int) -> list[float]: + if not isinstance(value, list) or len(value) != expected_len: + raise ValueError(f"Expected a list of {expected_len} floats") + return [float(item) for item in value] diff --git a/dimos/msgs/reconstruction_msgs/test_TSDFGrid.py b/dimos/msgs/reconstruction_msgs/test_TSDFGrid.py new file mode 100644 index 0000000000..f2322fc25c --- /dev/null +++ b/dimos/msgs/reconstruction_msgs/test_TSDFGrid.py @@ -0,0 +1,140 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import cast + +import numpy as np +import pytest +import rerun as rr + +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.reconstruction_msgs.ReconstructionStatus import ReconstructionStatus +from dimos.msgs.reconstruction_msgs.TSDFGrid import TSDFGrid + + +def test_tsdf_grid_shape_origin_and_voxel_position() -> None: + distances = np.arange(24, dtype=np.float32).reshape(1, 2, 3, 4) + grid = TSDFGrid( + distances=distances, + voxel_size=0.05, + truncation_distance=0.15, + origin=Vector3(1.0, 2.0, 3.0), + frame_id="map", + ts=12.25, + ) + + assert grid.distances.shape == (1, 2, 3, 4) + assert grid.distances.dtype == np.float32 + assert grid.resolution == (2, 3, 4) + assert grid.size == pytest.approx((0.1, 0.15, 0.2)) + assert ( + grid.voxel_position(0, 0, 0).x, + grid.voxel_position(0, 0, 0).y, + grid.voxel_position(0, 0, 0).z, + ) == pytest.approx((1.0, 2.0, 3.0)) + assert ( + grid.voxel_position(1, 2, 3).x, + grid.voxel_position(1, 2, 3).y, + grid.voxel_position(1, 2, 3).z, + ) == pytest.approx((1.05, 2.1, 3.15)) + + +def test_tsdf_grid_lcm_round_trip_preserves_arrays_and_metadata() -> None: + distances = np.linspace(-1.0, 1.0, 24, dtype=np.float32).reshape(1, 2, 3, 4) + weights = np.linspace(0.0, 2.3, 24, dtype=np.float32).reshape(1, 2, 3, 4) + grid = TSDFGrid( + distances=distances, + weights=weights, + voxel_size=0.02, + truncation_distance=0.08, + origin=Vector3(-0.1, 0.2, 0.3), + frame_id="camera", + ts=42.5, + ) + + decoded = TSDFGrid.lcm_decode(grid.lcm_encode()) + + assert decoded.distances.shape == distances.shape + assert decoded.distances.dtype == np.float32 + np.testing.assert_array_equal(decoded.distances, distances) + assert decoded.weights is not None + assert decoded.weights.shape == weights.shape + assert decoded.weights.dtype == np.float32 + np.testing.assert_array_equal(decoded.weights, weights) + assert decoded.voxel_size == pytest.approx(0.02) + assert decoded.truncation_distance == pytest.approx(0.08) + assert (decoded.origin.x, decoded.origin.y, decoded.origin.z) == pytest.approx((-0.1, 0.2, 0.3)) + assert decoded.frame_id == "camera" + assert decoded.ts == pytest.approx(42.5) + + +def test_tsdf_grid_rejects_invalid_shape() -> None: + with pytest.raises(ValueError, match="shape"): + TSDFGrid(np.zeros((2, 3, 4), dtype=np.float32), voxel_size=0.1, truncation_distance=0.2) + + with pytest.raises(ValueError, match="shape"): + TSDFGrid(np.zeros((2, 2, 3, 4), dtype=np.float32), voxel_size=0.1, truncation_distance=0.2) + + +def test_tsdf_grid_to_rerun_filters_unobserved_surface_voxels() -> None: + distances = np.ones((1, 2, 2, 2), dtype=np.float32) + distances[0, 0, 0, 0] = 0.0 + distances[0, 1, 1, 1] = 0.0 + weights = np.zeros((2, 2, 2), dtype=np.float32) + weights[1, 1, 1] = 1.0 + grid = TSDFGrid( + distances=distances, + weights=weights, + voxel_size=0.05, + truncation_distance=0.2, + origin=Vector3(), + ) + + archetype = grid.to_rerun() + + assert isinstance(archetype, rr.Points3D) + points = cast("rr.Points3D", archetype) + assert len(points.positions.as_arrow_array().to_pylist()) == 1 + + +def test_reconstruction_status_lcm_round_trip() -> None: + status = ReconstructionStatus( + integrated_frames=7, + dropped_frames=2, + last_error="sensor timeout", + active=True, + paused=True, + latest_integration_ts=99.5, + workspace_origin=Vector3(0.1, -0.2, 0.3), + workspace_size=0.75, + frame_id="base", + ts=101.125, + ) + + decoded = ReconstructionStatus.lcm_decode(status.lcm_encode()) + + assert decoded.integrated_frames == 7 + assert decoded.dropped_frames == 2 + assert decoded.last_error == "sensor timeout" + assert decoded.active is True + assert decoded.paused is True + assert decoded.latest_integration_ts == pytest.approx(99.5) + assert ( + decoded.workspace_origin.x, + decoded.workspace_origin.y, + decoded.workspace_origin.z, + ) == pytest.approx((0.1, -0.2, 0.3)) + assert decoded.workspace_size == pytest.approx(0.75) + assert decoded.frame_id == "base" + assert decoded.ts == pytest.approx(101.125) diff --git a/dimos/perception/object_scene_registration.py b/dimos/perception/object_scene_registration.py index 9689e5b7b9..1988d4d993 100644 --- a/dimos/perception/object_scene_registration.py +++ b/dimos/perception/object_scene_registration.py @@ -24,6 +24,8 @@ from dimos.core.core import rpc from dimos.core.module import Module from dimos.core.stream import In, Out +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo from dimos.msgs.sensor_msgs.Image import Image, ImageFormat from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 @@ -45,6 +47,11 @@ logger = setup_logger() +_REGISTERED_BOUNDS_LOW_PERCENTILE = 5.0 +_REGISTERED_BOUNDS_HIGH_PERCENTILE = 95.0 +_REGISTERED_BOUNDS_MIN_POINTS = 20 +_REGISTERED_BOUNDS_MIN_EXTENT_M = 0.01 + class ObjectSceneRegistrationModule(Module): """Module for detecting objects in camera images using YOLO-E with 2D and 3D detection.""" @@ -154,6 +161,20 @@ def get_detected_objects(self) -> list[dict[str, Any]]: """Get all detected objects with object_id (UUID) and name.""" return [obj.agent_encode() for obj in self._object_db.get_all_objects()] + @rpc + def get_registered_objects(self) -> list[RegisteredObject]: + """Get all runtime registered objects with target-bounds metadata.""" + return [_to_registered_object(obj) for obj in self._object_db.get_all_objects()] + + @rpc + def get_object_by_object_id(self, object_id: str) -> RegisteredObject | None: + """Get registered object metadata by stable object_id.""" + obj = self._object_db.find_by_object_id(object_id) + if obj is None: + logger.warning(f"No object found with object_id='{object_id}'") + return None + return _to_registered_object(obj) + @rpc def get_object_pointcloud_by_name(self, name: str) -> PointCloud2 | None: """Get pointcloud for an object by class name.""" @@ -370,3 +391,54 @@ def _process_3d_detections( aggregated_pc = aggregate_pointclouds(objects_for_pc) self.pointcloud.publish(aggregated_pc) return + + +def _to_registered_object(obj: Object) -> RegisteredObject: + """Convert an internal detection Object to frame-axis-aligned bounds.""" + pointcloud = obj.pointcloud.pointcloud if obj.pointcloud is not None else None + if pointcloud is not None and len(pointcloud.points) > 0: + center, extent = _registered_bounds_from_points(np.asarray(pointcloud.points)) + return RegisteredObject( + object_id=obj.object_id, + name=obj.name, + center=Vector3(center[0], center[1], center[2]), + size=Vector3(float(extent[0]), float(extent[1]), float(extent[2])), + frame_id=obj.pointcloud.frame_id or obj.frame_id, + ts=obj.ts, + ) + + return RegisteredObject( + object_id=obj.object_id, + name=obj.name, + center=obj.center, + size=obj.size, + frame_id=obj.frame_id, + ts=obj.ts, + ) + + +def _registered_bounds_from_points( + points: NDArray[np.float64], +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Return robust frame-axis-aligned bounds for a registered object pointcloud. + + Object pointclouds may contain a few table/background/depth outliers after + mask projection or database aggregation. Percentile bounds keep the target + bounds useful for grasp attention without letting isolated outliers dominate + the AABB. Small point sets fall back to the exact AABB to avoid cutting away + sparse objects. + """ + finite_points = points[np.isfinite(points).all(axis=1)] + if len(finite_points) == 0: + return np.zeros(3, dtype=np.float64), np.zeros(3, dtype=np.float64) + + if len(finite_points) >= _REGISTERED_BOUNDS_MIN_POINTS: + min_bound = np.percentile(finite_points, _REGISTERED_BOUNDS_LOW_PERCENTILE, axis=0) + max_bound = np.percentile(finite_points, _REGISTERED_BOUNDS_HIGH_PERCENTILE, axis=0) + else: + min_bound = finite_points.min(axis=0) + max_bound = finite_points.max(axis=0) + + extent = np.maximum(max_bound - min_bound, _REGISTERED_BOUNDS_MIN_EXTENT_M) + center = (min_bound + max_bound) / 2.0 + return center.astype(np.float64), extent.astype(np.float64) diff --git a/dimos/perception/object_scene_registration_spec.py b/dimos/perception/object_scene_registration_spec.py index 59aae79cab..1665e08c1f 100644 --- a/dimos/perception/object_scene_registration_spec.py +++ b/dimos/perception/object_scene_registration_spec.py @@ -14,11 +14,22 @@ from typing import Protocol +import numpy as np +from numpy.typing import NDArray + +from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 from dimos.spec.utils import Spec class ObjectSceneRegistrationSpec(Spec, Protocol): + def get_object_by_object_id(self, object_id: str) -> RegisteredObject | None: ... + def get_registered_objects(self) -> list[RegisteredObject]: ... + def set_prompts( + self, + text: list[str] | None = None, + bboxes: NDArray[np.float64] | None = None, + ) -> None: ... def get_object_pointcloud_by_name(self, name: str) -> PointCloud2 | None: ... def get_object_pointcloud_by_object_id(self, object_id: str) -> PointCloud2 | None: ... def get_full_scene_pointcloud( diff --git a/dimos/perception/reconstruction/__init__.py b/dimos/perception/reconstruction/__init__.py new file mode 100644 index 0000000000..2385f342df --- /dev/null +++ b/dimos/perception/reconstruction/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.perception.reconstruction.scene_reconstruction import ( + SceneReconstructionModule, + SceneReconstructionModuleConfig, +) + +__all__ = ["SceneReconstructionModule", "SceneReconstructionModuleConfig"] diff --git a/dimos/perception/reconstruction/scene_reconstruction.py b/dimos/perception/reconstruction/scene_reconstruction.py new file mode 100644 index 0000000000..9551058803 --- /dev/null +++ b/dimos/perception/reconstruction/scene_reconstruction.py @@ -0,0 +1,366 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import numpy as np +import open3d as o3d # type: ignore[import-untyped] +from reactivex.disposable import Disposable + +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import In, Out +from dimos.msgs.geometry_msgs.Transform import Transform +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.reconstruction_msgs.ReconstructionStatus import ReconstructionStatus +from dimos.msgs.reconstruction_msgs.TSDFGrid import TSDFGrid +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.utils.logging_config import setup_logger + +if TYPE_CHECKING: + from numpy.typing import NDArray + +logger = setup_logger() + + +class SceneReconstructionModuleConfig(ModuleConfig): + target_frame: str = "world" + workspace_center: tuple[float, float, float] = (0.45, 0.0, 0.18) + workspace_size: float = 0.3 + resolution: int = 40 + truncation_distance: float | None = None + reconstruction_fps: float = 2.0 + depth_trunc: float = 2.0 + depth16_scale: float = 0.001 + tf_time_tolerance: float = 0.1 + + +class SceneReconstructionModule(Module): + """Integrate depth images into a TSDF and reconstructed scene pointcloud.""" + + config: SceneReconstructionModuleConfig + + depth_image: In[Image] + depth_camera_info: In[CameraInfo] + + scene_pointcloud: Out[PointCloud2] + tsdf: Out[TSDFGrid] + status: Out[ReconstructionStatus] + + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) + self._camera_info: CameraInfo | None = None + self._workspace_origin = _origin_from_center( + self.config.workspace_center, + self.config.workspace_size, + ) + self._integrated_frames = 0 + self._dropped_frames = 0 + self._latest_integration_ts: float | None = None + self._last_error = "" + self._paused = False + self._active = True + self._last_publish_ts: float | None = None + self._volume = self._new_volume() + + @rpc + def start(self) -> None: + super().start() + self._active = True + self.register_disposable( + Disposable(self.depth_camera_info.subscribe(self._set_camera_info)) + ) + self.register_disposable(Disposable(self.depth_image.subscribe(self._process_depth_image))) + + @rpc + def stop(self) -> None: + self._active = False + super().stop() + + @rpc + def reset_scene(self) -> str: + """Clear integrated scene data and reset reconstruction counters.""" + self._volume = self._new_volume() + self._integrated_frames = 0 + self._dropped_frames = 0 + self._latest_integration_ts = None + self._last_error = "" + self._last_publish_ts = None + self._publish_outputs(time.time()) + return "Scene reconstruction reset" + + @rpc + def pause_integration(self) -> str: + """Pause depth integration without clearing the current reconstruction.""" + self._paused = True + self._publish_status(time.time()) + return "Scene reconstruction paused" + + @rpc + def resume_integration(self) -> str: + """Resume depth integration.""" + self._paused = False + self._publish_status(time.time()) + return "Scene reconstruction resumed" + + @rpc + def set_workspace( + self, + center_x: float, + center_y: float, + center_z: float, + size: float | None = None, + ) -> str: + """Set cubic workspace by center; internally stores a min-corner origin.""" + workspace_size = float(size if size is not None else self.config.workspace_size) + if workspace_size <= 0: + raise ValueError("workspace size must be positive") + self._workspace_origin = _origin_from_center((center_x, center_y, center_z), workspace_size) + self.config.workspace_size = workspace_size + self._volume = self._new_volume() + self._integrated_frames = 0 + self._latest_integration_ts = None + self._last_publish_ts = None + self._publish_outputs(time.time()) + return "Scene reconstruction workspace updated" + + @rpc + def snapshot_scene(self) -> str: + """Publish the current reconstructed pointcloud, TSDF, and status streams.""" + self._publish_outputs(time.time()) + return "Scene reconstruction snapshot published" + + @rpc + def get_reconstruction_status(self) -> ReconstructionStatus: + """Return current reconstruction status metadata.""" + return self._make_status(time.time()) + + @property + def workspace_origin(self) -> Vector3: + return self._workspace_origin + + def _set_camera_info(self, camera_info: CameraInfo) -> None: + self._camera_info = camera_info + + def _process_depth_image(self, depth_image: Image) -> None: + if not self._active or self._paused: + return + if self._camera_info is None: + self._drop_frame("missing depth camera info") + return + if not _camera_info_matches_depth(self._camera_info, depth_image): + self._drop_frame("depth image does not match camera info") + return + + camera_transform = self._lookup_camera_transform(depth_image) + if camera_transform is None: + self._drop_frame("missing transform") + return + + try: + self._integrate_depth(depth_image, self._camera_info, camera_transform) + except (RuntimeError, ValueError) as exc: + self._drop_frame(str(exc)) + logger.warning("Failed to integrate depth frame: %s", exc) + return + + self._integrated_frames += 1 + self._latest_integration_ts = depth_image.ts + self._last_error = "" + + if self._should_publish(depth_image.ts): + self._publish_outputs(depth_image.ts) + + def _lookup_camera_transform(self, depth_image: Image) -> Transform | None: + if self.config.target_frame == depth_image.frame_id: + return Transform( + frame_id=self.config.target_frame, + child_frame_id=depth_image.frame_id, + ts=depth_image.ts, + ) + return self.tf.get( + self.config.target_frame, + depth_image.frame_id, + depth_image.ts, + self.config.tf_time_tolerance, + ) + + def _integrate_depth( + self, + depth_image: Image, + camera_info: CameraInfo, + target_from_camera: Transform, + ) -> None: + depth_m = _depth_to_meters(depth_image, self.config.depth16_scale) + if depth_m.shape != (camera_info.height, camera_info.width): + raise ValueError( + f"depth image shape {depth_m.shape} does not match camera info " + f"{camera_info.height}x{camera_info.width}" + ) + + valid_depth = np.isfinite(depth_m) & (depth_m > 0.0) + if not np.any(valid_depth): + raise ValueError("depth image contains no valid depth pixels") + depth_filtered = depth_m.copy() + depth_filtered[~valid_depth] = 0.0 + + color = np.zeros((*depth_filtered.shape, 3), dtype=np.uint8) + rgbd = o3d.geometry.RGBDImage.create_from_color_and_depth( + o3d.geometry.Image(color), + o3d.geometry.Image(np.ascontiguousarray(depth_filtered.astype(np.float32))), + depth_scale=1.0, + depth_trunc=self.config.depth_trunc, + convert_rgb_to_intensity=False, + ) + intrinsic = _make_intrinsic(camera_info) + self._volume.integrate( + rgbd, intrinsic, self._camera_from_workspace_matrix(target_from_camera) + ) + + def _new_volume(self) -> o3d.pipelines.integration.UniformTSDFVolume: + resolution = int(self.config.resolution) + if resolution <= 0: + raise ValueError("TSDF resolution must be positive") + workspace_size = float(self.config.workspace_size) + truncation = self.config.truncation_distance + truncation_distance = ( + float(truncation) if truncation is not None else 4.0 * workspace_size / resolution + ) + return o3d.pipelines.integration.UniformTSDFVolume( + length=workspace_size, + resolution=resolution, + sdf_trunc=truncation_distance, + color_type=o3d.pipelines.integration.TSDFVolumeColorType.NoColor, + ) + + def _camera_from_workspace_matrix(self, target_from_camera: Transform) -> NDArray[np.float64]: + target_from_workspace = np.eye(4, dtype=np.float64) + target_from_workspace[:3, 3] = [ + self._workspace_origin.x, + self._workspace_origin.y, + self._workspace_origin.z, + ] + camera_from_target = np.linalg.inv(target_from_camera.to_matrix()).astype(np.float64) + return camera_from_target @ target_from_workspace + + def _extract_tsdf_grid(self, ts: float) -> TSDFGrid: + raw = np.asarray(self._volume.extract_volume_tsdf(), dtype=np.float32) + resolution = int(self.config.resolution) + expected = resolution * resolution * resolution + if raw.shape != (expected, 2): + raise RuntimeError(f"Open3D returned unexpected TSDF shape {raw.shape}") + field = raw[:, 0].reshape((1, resolution, resolution, resolution)).astype(np.float32) + weights = raw[:, 1].reshape((resolution, resolution, resolution)).astype(np.float32) + distances = np.where( + weights.reshape((1, resolution, resolution, resolution)) > 0.0, field, 1.0 + ) + return TSDFGrid( + distances=distances, + voxel_size=float(self.config.workspace_size) / resolution, + truncation_distance=self._truncation_distance, + origin=self._workspace_origin, + weights=weights, + frame_id=self.config.target_frame, + ts=ts, + ) + + @property + def _truncation_distance(self) -> float: + if self.config.truncation_distance is not None: + return float(self.config.truncation_distance) + return 4.0 * float(self.config.workspace_size) / int(self.config.resolution) + + def _extract_scene_pointcloud(self, ts: float) -> PointCloud2: + pointcloud = self._volume.extract_point_cloud() + if len(pointcloud.points) > 0: + translation = np.eye(4, dtype=np.float64) + translation[:3, 3] = [ + self._workspace_origin.x, + self._workspace_origin.y, + self._workspace_origin.z, + ] + pointcloud.transform(translation) + return PointCloud2(pointcloud=pointcloud, frame_id=self.config.target_frame, ts=ts) + + def _publish_outputs(self, ts: float) -> None: + self.scene_pointcloud.publish(self._extract_scene_pointcloud(ts)) + self.tsdf.publish(self._extract_tsdf_grid(ts)) + self._publish_status(ts) + self._last_publish_ts = ts + + def _publish_status(self, ts: float) -> None: + self.status.publish(self._make_status(ts)) + + def _make_status(self, ts: float) -> ReconstructionStatus: + return ReconstructionStatus( + integrated_frames=self._integrated_frames, + dropped_frames=self._dropped_frames, + last_error=self._last_error, + active=self._active, + paused=self._paused, + latest_integration_ts=self._latest_integration_ts, + workspace_origin=self._workspace_origin, + workspace_size=self.config.workspace_size, + frame_id=self.config.target_frame, + ts=ts, + ) + + def _drop_frame(self, reason: str) -> None: + self._dropped_frames += 1 + self._last_error = reason + self._publish_status(time.time()) + + def _should_publish(self, ts: float) -> bool: + if self._last_publish_ts is None: + return True + fps = float(self.config.reconstruction_fps) + if fps <= 0.0: + return False + return ts - self._last_publish_ts >= 1.0 / fps + + +def _origin_from_center(center: tuple[float, float, float], size: float) -> Vector3: + half = float(size) / 2.0 + return Vector3(float(center[0]) - half, float(center[1]) - half, float(center[2]) - half) + + +def _camera_info_matches_depth(camera_info: CameraInfo, depth_image: Image) -> bool: + return depth_image.data.shape[:2] == (camera_info.height, camera_info.width) + + +def _depth_to_meters(depth_image: Image, depth16_scale: float) -> NDArray[np.float32]: + depth_obj: object = depth_image.to_opencv() + if not isinstance(depth_obj, np.ndarray) and hasattr(depth_obj, "get"): + depth_obj = depth_obj.get() + arr = np.asarray(depth_obj) + if depth_image.format == ImageFormat.DEPTH16 or arr.dtype == np.uint16: + return np.ascontiguousarray(arr.astype(np.float32) * np.float32(depth16_scale)) + return np.ascontiguousarray(arr.astype(np.float32)) + + +def _make_intrinsic(camera_info: CameraInfo) -> o3d.camera.PinholeCameraIntrinsic: + intrinsic = camera_info.get_K_matrix() + return o3d.camera.PinholeCameraIntrinsic( + width=camera_info.width, + height=camera_info.height, + fx=float(intrinsic[0, 0]), + fy=float(intrinsic[1, 1]), + cx=float(intrinsic[0, 2]), + cy=float(intrinsic[1, 2]), + ) diff --git a/dimos/perception/reconstruction/test_scene_reconstruction.py b/dimos/perception/reconstruction/test_scene_reconstruction.py new file mode 100644 index 0000000000..281a56a01c --- /dev/null +++ b/dimos/perception/reconstruction/test_scene_reconstruction.py @@ -0,0 +1,167 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Generator + +import numpy as np +import pytest +from pytest_mock import MockerFixture + +from dimos.msgs.reconstruction_msgs.ReconstructionStatus import ReconstructionStatus +from dimos.msgs.reconstruction_msgs.TSDFGrid import TSDFGrid +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.perception.reconstruction.scene_reconstruction import SceneReconstructionModule + + +def _camera_info(width: int = 4, height: int = 4, frame_id: str = "camera") -> CameraInfo: + return CameraInfo.from_intrinsics( + fx=100.0, + fy=100.0, + cx=(width - 1) / 2.0, + cy=(height - 1) / 2.0, + width=width, + height=height, + frame_id=frame_id, + ) + + +def _depth_image( + ts: float = 1.0, frame_id: str = "camera", width: int = 4, height: int = 4 +) -> Image: + return Image( + data=np.full((height, width), 0.6, dtype=np.float32), + format=ImageFormat.DEPTH, + frame_id=frame_id, + ts=ts, + ) + + +def _module(**kwargs: object) -> SceneReconstructionModule: + return SceneReconstructionModule( + target_frame="world", + workspace_center=(0.0, 0.0, 0.6), + workspace_size=1.0, + depth_trunc=2.0, + **kwargs, + ) + + +@pytest.fixture(autouse=True) +def _stop_created_modules(mocker: MockerFixture) -> Generator[None, None, None]: + modules: list[SceneReconstructionModule] = [] + original_init = SceneReconstructionModule.__init__ + + def tracked_init(self: SceneReconstructionModule, **kwargs: object) -> None: + original_init(self, **kwargs) + modules.append(self) + + mocker.patch.object(SceneReconstructionModule, "__init__", tracked_init) + yield + for module in modules: + module.stop() + + +def test_set_workspace_stores_min_corner_and_resets_reconstruction() -> None: + module = _module() + original_volume = module._volume + module._integrated_frames = 3 + module._dropped_frames = 2 + module._latest_integration_ts = 123.0 + + result = module.set_workspace(1.0, 2.0, 3.0, 2.0) + + status = module.get_reconstruction_status() + assert result == "Scene reconstruction workspace updated" + assert module._volume is not original_volume + assert module._integrated_frames == 0 + assert module._latest_integration_ts is None + assert module._last_publish_ts is not None + assert status.integrated_frames == 0 + assert status.workspace_size == 2.0 + assert status.workspace_origin.x == pytest.approx(0.0) + assert status.workspace_origin.y == pytest.approx(1.0) + assert status.workspace_origin.z == pytest.approx(2.0) + + +def test_missing_camera_info_drops_frame_and_publishes_status() -> None: + module = _module() + statuses: list[ReconstructionStatus] = [] + module.status.subscribe(statuses.append) + + module._process_depth_image(_depth_image()) + + assert module._integrated_frames == 0 + assert module._dropped_frames == 1 + assert len(statuses) == 1 + assert statuses[0].dropped_frames == 1 + assert statuses[0].last_error == "missing depth camera info" + + +def test_missing_tf_drops_frame_and_publishes_status(mocker: MockerFixture) -> None: + module = SceneReconstructionModule(target_frame="world") + module._set_camera_info(_camera_info(frame_id="world")) + statuses: list[ReconstructionStatus] = [] + module.status.subscribe(statuses.append) + mocker.patch.object(module.tf, "get", return_value=None) + + module._process_depth_image(_depth_image()) + + assert module._integrated_frames == 0 + assert module._dropped_frames == 1 + assert len(statuses) == 1 + assert statuses[0].last_error == "missing transform" + + +def test_valid_identity_depth_frame_integrates_and_publishes_outputs() -> None: + module = _module() + module._set_camera_info(_camera_info(frame_id="world")) + pointclouds: list[PointCloud2] = [] + tsdfs: list[TSDFGrid] = [] + statuses: list[ReconstructionStatus] = [] + module.scene_pointcloud.subscribe(pointclouds.append) + module.tsdf.subscribe(tsdfs.append) + module.status.subscribe(statuses.append) + + module._process_depth_image(_depth_image(ts=1.0, frame_id="world")) + + assert len(pointclouds) == 1 + assert len(tsdfs) == 1 + assert len(statuses) == 1 + assert tsdfs[0].distances.shape == (1, 40, 40, 40) + assert tsdfs[0].frame_id == "world" + assert pointclouds[0].frame_id == "world" + assert statuses[0].integrated_frames == 1 + assert statuses[0].latest_integration_ts == 1.0 + + +def test_publication_throttling_integrates_but_skips_outputs() -> None: + module = _module(reconstruction_fps=1.0) + module._set_camera_info(_camera_info(frame_id="world")) + pointclouds: list[PointCloud2] = [] + tsdfs: list[TSDFGrid] = [] + statuses: list[ReconstructionStatus] = [] + module.scene_pointcloud.subscribe(pointclouds.append) + module.tsdf.subscribe(tsdfs.append) + module.status.subscribe(statuses.append) + + module._process_depth_image(_depth_image(ts=1.0, frame_id="world")) + module._process_depth_image(_depth_image(ts=1.2, frame_id="world")) + + assert module._integrated_frames == 2 + assert len(pointclouds) == 1 + assert len(tsdfs) == 1 + assert len(statuses) == 1 + assert statuses[0].integrated_frames == 1 diff --git a/dimos/perception/reconstruction/test_tsdf_debug_export.py b/dimos/perception/reconstruction/test_tsdf_debug_export.py new file mode 100644 index 0000000000..b6eaca2f45 --- /dev/null +++ b/dimos/perception/reconstruction/test_tsdf_debug_export.py @@ -0,0 +1,55 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import numpy as np +import open3d as o3d # type: ignore[import-untyped] + +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.reconstruction_msgs.TSDFGrid import TSDFGrid +from dimos.perception.reconstruction.tsdf_debug_export import export_tsdf_debug_files + + +def test_export_tsdf_debug_files_writes_raw_grid_and_open3d_pointclouds(tmp_path) -> None: # type: ignore[no-untyped-def] + distances = np.ones((1, 3, 3, 3), dtype=np.float32) + distances[0, 1, 1, 1] = 0.0 + weights = np.zeros((3, 3, 3), dtype=np.float32) + weights[1, 1, 1] = 2.0 + weights[2, 2, 2] = 1.0 + grid = TSDFGrid( + distances=distances, + weights=weights, + voxel_size=0.05, + truncation_distance=0.2, + origin=Vector3(0.1, 0.2, 0.3), + frame_id="world", + ts=12.0, + ) + + paths = export_tsdf_debug_files(grid, tmp_path, "target masked/tsdf") + + assert {path.name for path in paths} == { + "target_masked_tsdf.npz", + "target_masked_tsdf_near_surface.ply", + "target_masked_tsdf_observed.ply", + } + with np.load(tmp_path / "target_masked_tsdf.npz", allow_pickle=False) as data: + np.testing.assert_array_equal(data["distances"], distances) + np.testing.assert_array_equal(data["weights"], weights) + np.testing.assert_allclose(data["origin"], np.array([0.1, 0.2, 0.3], dtype=np.float32)) + assert str(data["frame_id"]) == "world" + + near_surface = o3d.io.read_point_cloud(str(tmp_path / "target_masked_tsdf_near_surface.ply")) + observed = o3d.io.read_point_cloud(str(tmp_path / "target_masked_tsdf_observed.ply")) + assert len(near_surface.points) == 1 + assert len(observed.points) == 2 diff --git a/dimos/perception/reconstruction/tsdf_debug_export.py b/dimos/perception/reconstruction/tsdf_debug_export.py new file mode 100644 index 0000000000..01123ba1af --- /dev/null +++ b/dimos/perception/reconstruction/tsdf_debug_export.py @@ -0,0 +1,88 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from pathlib import Path +import re + +import numpy as np +import open3d as o3d # type: ignore[import-untyped] + +from dimos.msgs.reconstruction_msgs.TSDFGrid import TSDFGrid + + +def export_tsdf_debug_files(tsdf: TSDFGrid, output_dir: str | Path, prefix: str) -> list[Path]: + """Write TSDF debug artifacts for offline inspection. + + The raw ``.npz`` is the authoritative grid. The ``.ply`` files are helper + visualizations that can be opened in Open3D, MeshLab, or CloudCompare. + """ + root = Path(output_dir) + root.mkdir(parents=True, exist_ok=True) + safe_prefix = _safe_prefix(prefix) + + npz_path = root / f"{safe_prefix}.npz" + near_surface_path = root / f"{safe_prefix}_near_surface.ply" + observed_path = root / f"{safe_prefix}_observed.ply" + + np.savez_compressed( + npz_path, + distances=tsdf.distances, + weights=tsdf.weights if tsdf.weights is not None else np.array([], dtype=np.float32), + origin=np.array([tsdf.origin.x, tsdf.origin.y, tsdf.origin.z], dtype=np.float32), + voxel_size=np.array(tsdf.voxel_size, dtype=np.float32), + truncation_distance=np.array(tsdf.truncation_distance, dtype=np.float32), + frame_id=np.array(tsdf.frame_id), + ts=np.array(tsdf.ts, dtype=np.float64), + ) + + _write_point_cloud(near_surface_path, _near_surface_points(tsdf)) + _write_point_cloud(observed_path, _observed_points(tsdf)) + return [npz_path, near_surface_path, observed_path] + + +def _near_surface_points(tsdf: TSDFGrid) -> np.ndarray: + field = tsdf.distances[0] + mask = np.abs(field) <= tsdf.voxel_size + if tsdf.weights is not None: + weights = tsdf.weights[0] if tsdf.weights.ndim == 4 else tsdf.weights + mask = np.logical_and(mask, weights > 0.0) + return _points_from_mask(tsdf, mask) + + +def _observed_points(tsdf: TSDFGrid) -> np.ndarray: + if tsdf.weights is None: + return np.empty((0, 3), dtype=np.float64) + weights = tsdf.weights[0] if tsdf.weights.ndim == 4 else tsdf.weights + return _points_from_mask(tsdf, weights > 0.0) + + +def _points_from_mask(tsdf: TSDFGrid, mask: np.ndarray) -> np.ndarray: + indices = np.argwhere(mask) + if len(indices) == 0: + return np.empty((0, 3), dtype=np.float64) + origin = np.array([tsdf.origin.x, tsdf.origin.y, tsdf.origin.z], dtype=np.float64) + return origin + indices.astype(np.float64) * tsdf.voxel_size + + +def _write_point_cloud(path: Path, points: np.ndarray) -> None: + pcd = o3d.geometry.PointCloud() + if len(points) > 0: + pcd.points = o3d.utility.Vector3dVector(points) + o3d.io.write_point_cloud(str(path), pcd, write_ascii=False) + + +def _safe_prefix(prefix: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "_", prefix).strip("_") or "tsdf" diff --git a/dimos/perception/test_object_scene_registration_registered_object.py b/dimos/perception/test_object_scene_registration_registered_object.py new file mode 100644 index 0000000000..19699527ee --- /dev/null +++ b/dimos/perception/test_object_scene_registration_registered_object.py @@ -0,0 +1,97 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import open3d as o3d # type: ignore[import-untyped] + +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.perception.object_scene_registration import _to_registered_object + + +@dataclass(slots=True) +class _ObjectWithPointcloud: + object_id: str + name: str + center: Vector3 + size: Vector3 + frame_id: str + ts: float + pointcloud: PointCloud2 | None + + +def _pointcloud(points: np.ndarray, frame_id: str = "world") -> PointCloud2: + pcd = o3d.geometry.PointCloud() + pcd.points = o3d.utility.Vector3dVector(points) + return PointCloud2(pointcloud=pcd, frame_id=frame_id, ts=12.5) + + +def test_registered_object_bounds_ignore_isolated_pointcloud_outlier() -> None: + cluster = np.array( + [ + [-0.02, -0.02, -0.02], + [-0.02, 0.02, 0.02], + [0.02, -0.02, 0.02], + [0.02, 0.02, -0.02], + ] + * 8, + dtype=np.float64, + ) + points = np.vstack([cluster, np.array([[1.0, 1.0, 1.0]], dtype=np.float64)]) + obj = _ObjectWithPointcloud( + object_id="obj-1", + name="orange", + center=Vector3(0.5, 0.5, 0.5), + size=Vector3(1.0, 1.0, 1.0), + frame_id="fallback", + ts=12.5, + pointcloud=_pointcloud(points, frame_id="camera"), + ) + + registered = _to_registered_object(obj) # type: ignore[arg-type] + + assert registered.object_id == "obj-1" + assert registered.name == "orange" + assert registered.frame_id == "camera" + assert registered.ts == 12.5 + assert abs(registered.center.x) < 0.08 + assert abs(registered.center.y) < 0.08 + assert abs(registered.center.z) < 0.08 + assert registered.size.x < 0.2 + assert registered.size.y < 0.2 + assert registered.size.z < 0.2 + + +def test_registered_object_bounds_fall_back_to_object_metadata_without_pointcloud() -> None: + obj = _ObjectWithPointcloud( + object_id="obj-2", + name="sphere", + center=Vector3(0.4, -0.1, 0.2), + size=Vector3(0.08, 0.07, 0.06), + frame_id="world", + ts=22.0, + pointcloud=None, + ) + + registered = _to_registered_object(obj) # type: ignore[arg-type] + + assert registered.object_id == "obj-2" + assert registered.center == obj.center + assert registered.size == obj.size + assert registered.frame_id == "world" + assert registered.ts == 22.0 diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index b87aa2d87d..91177b482c 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -128,6 +128,7 @@ "unitree-go2-webrtc-keyboard-teleop": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_webrtc_keyboard_teleop:unitree_go2_webrtc_keyboard_teleop", "unitree-go2-webrtc-rage-keyboard-teleop": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_webrtc_rage_keyboard_teleop:unitree_go2_webrtc_rage_keyboard_teleop", "unity-sim": "dimos.simulation.unity.blueprint:unity_sim", + "vgn-mujoco-grasp-demo": "dimos.robot.manipulators.xarm.blueprints.simulation:vgn_mujoco_grasp_demo", "xarm-perception": "dimos.robot.manipulators.xarm.blueprints.perception:xarm_perception", "xarm-perception-agent": "dimos.robot.manipulators.xarm.blueprints.agentic:xarm_perception_agent", "xarm-perception-sim": "dimos.robot.manipulators.xarm.blueprints.simulation:xarm_perception_sim", @@ -229,6 +230,7 @@ "replanning-a-star-planner": "dimos.navigation.replanning_a_star.module.ReplanningAStarPlanner", "rerun-bridge-module": "dimos.visualization.rerun.bridge.RerunBridgeModule", "rerun-web-socket-server": "dimos.visualization.rerun.websocket_server.RerunWebSocketServer", + "scene-reconstruction-module": "dimos.perception.reconstruction.scene_reconstruction.SceneReconstructionModule", "security-module": "dimos.experimental.security_demo.security_module.SecurityModule", "semantic-search": "dimos.memory2.module.SemanticSearch", "simple-phone-teleop": "dimos.teleop.phone.phone_extensions.SimplePhoneTeleop", @@ -244,6 +246,7 @@ "unitree-g1-skill-container": "dimos.robot.unitree.g1.skill_container.UnitreeG1SkillContainer", "unitree-skill-container": "dimos.robot.unitree.unitree_skill_container.UnitreeSkillContainer", "unity-bridge-module": "dimos.simulation.unity.module.UnityBridgeModule", + "vgn-grasp-gen-module": "dimos.manipulation.grasping.vgn_grasp_gen_module.VGNGraspGenModule", "video-arm-teleop-module": "dimos.teleop.quest.quest_extensions.VideoArmTeleopModule", "virtual-mid360": "dimos.hardware.sensors.lidar.virtual_mid360.module.VirtualMid360", "vlm-agent": "dimos.agents.vlm_agent.VLMAgent", diff --git a/dimos/robot/manipulators/xarm/blueprints/simulation.py b/dimos/robot/manipulators/xarm/blueprints/simulation.py index d4c397f48b..b57ece52e9 100644 --- a/dimos/robot/manipulators/xarm/blueprints/simulation.py +++ b/dimos/robot/manipulators/xarm/blueprints/simulation.py @@ -19,8 +19,12 @@ import math from dimos.core.coordination.blueprints import autoconnect +from dimos.manipulation.grasping.grasping import GraspingModule +from dimos.manipulation.grasping.target_grasp_demo_controller import TargetGraspDemoController +from dimos.manipulation.grasping.vgn_grasp_gen_module import VGNGraspGenModule from dimos.manipulation.pick_and_place_module import PickAndPlaceModule from dimos.perception.object_scene_registration import ObjectSceneRegistrationModule +from dimos.perception.reconstruction import SceneReconstructionModule from dimos.robot.manipulators.common.blueprints import coordinator, trajectory_task from dimos.robot.manipulators.xarm.config import ( XARM7_SIM_PATH, @@ -31,6 +35,7 @@ from dimos.visualization.rerun.bridge import RerunBridgeModule XARM7_SIM_HOME = [0.0, 0.0, 0.0, 0.0, 0.0, -0.7, 0.0] +XARM7_VGN_OBSERVATION_HOME = [0.0, -0.247, 0.0, 0.909, 0.0, 1.15644, 0.0] _xarm7_sim_hw = make_xarm_hardware( "arm", @@ -70,3 +75,44 @@ ), RerunBridgeModule.blueprint(), ) + +vgn_mujoco_grasp_demo = autoconnect( + MujocoSimModule.blueprint( + address=str(XARM7_SIM_PATH), + headless=False, + dof=7, + camera_name="wrist_camera", + base_frame_id="link7", + enable_depth=True, + enable_color=True, + enable_pointcloud=False, + camera_info_fps=5.0, + initial_joint_positions=XARM7_VGN_OBSERVATION_HOME, + ), + SceneReconstructionModule.blueprint( + target_frame="world", + workspace_center=(0.45, 0.0, 0.18), + workspace_size=0.3, + resolution=40, + reconstruction_fps=2.0, + depth_trunc=2.0, + ), + ObjectSceneRegistrationModule.blueprint( + target_frame="world", + min_detections_for_permanent=1, + use_aabb=True, + ), + VGNGraspGenModule.blueprint( + output_frame="world", + auto_generate_on_tsdf=False, + debug_export_dir="/tmp/opencode/dimos-vgn-tsdf-debug", + ), + GraspingModule.blueprint(), + TargetGraspDemoController.blueprint(target_name="orange"), + RerunBridgeModule.blueprint(), +).remappings( + [ + (SceneReconstructionModule, "tsdf", "tsdf_surface"), + (VGNGraspGenModule, "tsdf", "tsdf_surface"), + ] +) diff --git a/dimos/robot/manipulators/xarm/blueprints/test_vgn_mujoco_grasp_demo.py b/dimos/robot/manipulators/xarm/blueprints/test_vgn_mujoco_grasp_demo.py new file mode 100644 index 0000000000..1ea4c4a4db --- /dev/null +++ b/dimos/robot/manipulators/xarm/blueprints/test_vgn_mujoco_grasp_demo.py @@ -0,0 +1,85 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.manipulation.grasping.grasping import GraspingModule +from dimos.manipulation.grasping.target_grasp_demo_controller import TargetGraspDemoController +from dimos.manipulation.grasping.vgn_grasp_gen_module import VGNGraspGenModule +from dimos.perception.object_scene_registration import ObjectSceneRegistrationModule +from dimos.perception.reconstruction import SceneReconstructionModule +from dimos.robot.manipulators.xarm.blueprints.simulation import ( + vgn_mujoco_grasp_demo, + xarm_perception_sim, +) +from dimos.simulation.engines.mujoco_sim_module import MujocoSimModule +from dimos.visualization.rerun.bridge import RerunBridgeModule + + +def test_vgn_mujoco_grasp_demo_blueprint_is_opt_in() -> None: + demo_modules = {atom.module for atom in vgn_mujoco_grasp_demo.blueprints} + existing_modules = {atom.module for atom in xarm_perception_sim.blueprints} + + assert demo_modules == { + MujocoSimModule, + SceneReconstructionModule, + ObjectSceneRegistrationModule, + VGNGraspGenModule, + GraspingModule, + TargetGraspDemoController, + RerunBridgeModule, + } + assert VGNGraspGenModule not in existing_modules + assert SceneReconstructionModule not in existing_modules + + +def test_vgn_mujoco_grasp_demo_uses_stable_rerun_topics() -> None: + assert ( + vgn_mujoco_grasp_demo.remapping_map[(SceneReconstructionModule, "tsdf")] == "tsdf_surface" + ) + assert vgn_mujoco_grasp_demo.remapping_map[(VGNGraspGenModule, "tsdf")] == "tsdf_surface" + + +def test_vgn_mujoco_grasp_demo_uses_target_controller_not_workspace_auto_generation() -> None: + grasp_atom = next( + atom for atom in vgn_mujoco_grasp_demo.blueprints if atom.module is VGNGraspGenModule + ) + controller_atom = next( + atom + for atom in vgn_mujoco_grasp_demo.blueprints + if atom.module is TargetGraspDemoController + ) + reconstruction_atom = next( + atom + for atom in vgn_mujoco_grasp_demo.blueprints + if atom.module is SceneReconstructionModule + ) + + assert grasp_atom.kwargs["auto_generate_on_tsdf"] is False + assert grasp_atom.kwargs["debug_export_dir"] == "/tmp/opencode/dimos-vgn-tsdf-debug" + assert controller_atom.kwargs["target_name"] == "orange" + assert grasp_atom.kwargs.get("auto_generate_on_tsdf") is False + assert reconstruction_atom.kwargs["workspace_center"] == (0.45, 0.0, 0.18) + assert reconstruction_atom.kwargs["resolution"] == 40 + + sim_atom = next( + atom for atom in vgn_mujoco_grasp_demo.blueprints if atom.module is MujocoSimModule + ) + assert sim_atom.kwargs["initial_joint_positions"] == [ + 0.0, + -0.247, + 0.0, + 0.909, + 0.0, + 1.15644, + 0.0, + ] diff --git a/dimos/simulation/engines/mujoco_engine.py b/dimos/simulation/engines/mujoco_engine.py index d9889027f4..b6b47edffa 100644 --- a/dimos/simulation/engines/mujoco_engine.py +++ b/dimos/simulation/engines/mujoco_engine.py @@ -16,7 +16,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path import threading @@ -445,6 +445,39 @@ def write_joint_command(self, command: JointState) -> None: self._set_effort_targets(command.effort) return + def reset_joint_positions(self, positions: Sequence[float]) -> None: + """Set simulated joint positions and hold them as position targets. + + This is intended for deterministic initial poses before ``connect()`` + starts the physics thread. It also works while connected, but callers + should prefer normal commands for runtime motion. + """ + if len(positions) > self._num_joints: + raise ValueError( + f"Initial joint pose has {len(positions)} joints, expected at most {self._num_joints}" + ) + + with self._lock: + self._command_mode = "position" + for i, position in enumerate(positions): + value = float(position) + mapping = self._joint_mappings[i] + if mapping.qpos_adr is not None: + self._data.qpos[mapping.qpos_adr] = value + for qpos_adr in mapping.tendon_qpos_adrs: + self._data.qpos[qpos_adr] = value + if mapping.dof_adr is not None: + self._data.qvel[mapping.dof_adr] = 0.0 + for dof_adr in mapping.tendon_dof_adrs: + self._data.qvel[dof_adr] = 0.0 + if mapping.actuator_id is not None: + self._data.ctrl[mapping.actuator_id] = value + self._joint_positions[i] = value + self._joint_velocities[i] = 0.0 + self._joint_position_targets[i] = value + + mujoco.mj_forward(self._model, self._data) + def _set_position_targets(self, positions: list[float]) -> None: if len(positions) > self._num_joints: raise ValueError( diff --git a/dimos/simulation/engines/mujoco_sim_module.py b/dimos/simulation/engines/mujoco_sim_module.py index ffc2effa39..4029a29054 100644 --- a/dimos/simulation/engines/mujoco_sim_module.py +++ b/dimos/simulation/engines/mujoco_sim_module.py @@ -218,6 +218,7 @@ class MujocoSimModuleConfig(ModuleConfig, DepthCameraConfig): enable_pointcloud: bool = False pointcloud_fps: float = 5.0 camera_info_fps: float = 1.0 + initial_joint_positions: list[float] | None = None # Inject menagerie/dimos-bundled mesh bytes (via # dimos.simulation.mujoco.model.get_assets) into MjModel.from_xml_string. # MJCFs that reference meshes by bare filename (G1 GR00T, Go2) need this; @@ -424,6 +425,9 @@ def start(self) -> None: else: self._imu_base_qpos_slice = None + if self.config.initial_joint_positions is not None: + self._engine.reset_joint_positions(self.config.initial_joint_positions) + # Wire SHM bridge hooks. self._sim_hooks = _WholeBodySimHooks( self._shm, @@ -647,6 +651,8 @@ def _publish_loop(self) -> None: last_timestamp = frame.timestamp ts = time.time() + self._publish_tf(ts, frame) + if self.config.enable_color: color_img = Image( data=frame.rgb, @@ -665,8 +671,6 @@ def _publish_loop(self) -> None: ) self.depth_image.publish(depth_img) - self._publish_tf(ts, frame) - published_count += 1 if published_count == 1: logger.info( diff --git a/dimos/simulation/engines/test_robot_sim_binding.py b/dimos/simulation/engines/test_robot_sim_binding.py index e06d115f7e..88eb5e04fe 100644 --- a/dimos/simulation/engines/test_robot_sim_binding.py +++ b/dimos/simulation/engines/test_robot_sim_binding.py @@ -122,6 +122,20 @@ def test_mujoco_engine_uses_robot_binding_joint_order(tmp_path: Path) -> None: assert engine.has_root_freejoint +def test_mujoco_engine_reset_joint_positions_sets_qpos_and_targets(tmp_path: Path) -> None: + xml_path = tmp_path / "robot_scene.xml" + _write_scene_then_robot_xml(xml_path) + engine = MujocoEngine(config_path=xml_path, headless=True, robot_sim_spec=_robot_spec()) + + engine.reset_joint_positions([0.25, -0.5]) + + assert engine.joint_positions == [0.25, -0.5] + assert engine.get_position_target(0) == 0.25 + assert engine.get_position_target(1) == -0.5 + assert engine.data.qpos[14] == pytest.approx(0.25) + assert engine.data.qpos[15] == pytest.approx(-0.5) + + def test_robot_binding_requires_configured_imu(tmp_path: Path) -> None: xml_path = tmp_path / "robot_scene.xml" _write_scene_then_robot_xml(xml_path) diff --git a/docs/usage/README.md b/docs/usage/README.md index 071b6fc0b2..49f38b6f0b 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -7,6 +7,7 @@ This page explains general concepts. - [Modules](/docs/usage/modules.md): The primary units of deployment in DimOS, modules run in parallel and are python classes. - [Streams](/docs/usage/sensor_streams/README.md): How modules communicate, a Pub / Sub system. - [Blueprints](/docs/usage/blueprints.md): a way to group modules together and define their connections to each other. +- [VGN MuJoCo Grasp Demo](/docs/usage/vgn_mujoco_grasp_demo.md): opt-in xArm7 TSDF reconstruction and grasp candidate visualization demo. - [RPC](/docs/usage/blueprints.md#calling-the-methods-of-other-modules): how one module can call a method on another module (arguments get serialized to JSON-like binary data). - [Skills](/docs/usage/blueprints.md#defining-skills): An RPC function, except it can be called by an AI agent (a tool for an AI). - Agents: AI that has an objective, access to stream data, and is capable of calling skills as tools. diff --git a/docs/usage/vgn_mujoco_grasp_demo.md b/docs/usage/vgn_mujoco_grasp_demo.md new file mode 100644 index 0000000000..f0d40dadfd --- /dev/null +++ b/docs/usage/vgn_mujoco_grasp_demo.md @@ -0,0 +1,36 @@ +# VGN MuJoCo Grasp Demo + +`vgn-mujoco-grasp-demo` is an opt-in xArm7 MuJoCo demo for target-conditioned TSDF reconstruction and VGN grasp candidate generation. It does not change `xarm-perception-sim`. + +The demo composes: + +- `MujocoSimModule` using `data/xarm7/scene.xml`, the `wrist_camera` depth stream, and a deterministic xArm7 observation pose based on the MJCF home keyframe. +- `SceneReconstructionModule`, publishing `scene_pointcloud`, `tsdf_surface`, and reconstruction status. +- `ObjectSceneRegistrationModule`, detecting the configured target prompt/name after startup and exposing robust percentile target bounds from the registered object pointcloud. +- `VGNGraspGenModule`, generating world-frame `grasp_candidates` only after a runtime registered object has been selected. +- `GraspingModule`, resolving the generated object id to target bounds and invoking target-conditioned VGN. +- `TargetGraspDemoController`, waiting for a matching runtime object id, publishing `grasp_target_bounds`, then calling the object-id grasp API. +- `RerunBridgeModule`, visualizing stable paths under `world/`. + +Run it with VGN installed and a model checkpoint path: + +```bash +uv sync --extra grasp +DIMOS_VGN_MODEL_PATH=/path/to/vgn_conv.pth uv run dimos run vgn-mujoco-grasp-demo +``` + +Expected Rerun entities: + +- `world/scene_pointcloud` +- `world/tsdf_surface` +- `world/grasp_target_bounds` +- `world/target_masked_tsdf` +- `world/grasp_candidates` + +The default target prompt/name is `orange`, the sphere near the workspace center in the xArm7 demo scene. The object id is not known at launch; it is generated by object registration after the demo starts. The controller only grasps a runtime registered object whose name matches the configured target and does not fall back to workspace-level VGN candidates. The demo uses a 1.5 cm cushion around robust target bounds before masking the TSDF. + +The TSDF visualizations are diagnostic voxel views, not watertight meshes. `world/tsdf_surface` and `world/target_masked_tsdf` render near-surface TSDF voxels as points. They can look blocky at the demo resolution, and the masked TSDF is expected to look cropped because voxels outside the cushioned target bounds are suppressed before VGN inference. + +For offline inspection, the demo writes TSDF debug artifacts to `/tmp/opencode/dimos-vgn-tsdf-debug/` whenever target-conditioned generation runs. Each run exports raw `.npz` grids plus Open3D-readable `.ply` point clouds for near-surface voxels and observed voxels, for both the full TSDF and target-masked TSDF. + +If no checkpoint path is set, the VGN module starts but reports a clear `DIMOS_VGN_MODEL_PATH` requirement when target-conditioned grasp generation runs. diff --git a/openspec/changes/vgn-mujoco-grasp-demo/.openspec.yaml b/openspec/changes/vgn-mujoco-grasp-demo/.openspec.yaml deleted file mode 100644 index de73b342e8..0000000000 --- a/openspec/changes/vgn-mujoco-grasp-demo/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-25 diff --git a/openspec/changes/vgn-mujoco-grasp-demo/design.md b/openspec/changes/vgn-mujoco-grasp-demo/design.md deleted file mode 100644 index 35026d6705..0000000000 --- a/openspec/changes/vgn-mujoco-grasp-demo/design.md +++ /dev/null @@ -1,97 +0,0 @@ -## Context - -The core VGN reconstruction change defines the TSDF scene product and VGN grasp candidate output. This companion change validates that pipeline in a full simulated setting. The existing xArm simulation blueprint already uses `MujocoSimModule` with `camera_name="wrist_camera"` and `base_frame_id="link7"`, and Rerun visualization is already available through `RerunBridgeModule` and `PointCloud2.to_rerun()`. - -The first demo should prove geometry and frame correctness, not manipulation execution. A simple scene with one object is enough to show whether wrist depth, TSDF reconstruction, VGN inference, and grasp candidate visualization line up. - -## Goals / Non-Goals - -**Goals:** -- Provide an opt-in MuJoCo demo that runs xArm7, wrist depth camera, scene reconstruction, VGN grasp generation, and Rerun visualization together. -- Use a minimal scene: robot, gripper, table, and one simple stable graspable object. -- Visualize scene pointcloud, optional TSDF surface/voxel points, and simplified gripper candidates in world coordinates. -- Make the best/top grasps visually distinct from lower-scoring candidates. -- Keep the demo deterministic enough for repeated local debugging. - -**Non-Goals:** -- Autonomous physical or simulated pick execution. -- Cluttered object piles or benchmark-quality grasp evaluation. -- Viser as the primary visualization path in v1. -- General multi-object scene generation. - -## Decisions - -### Use Rerun first - -Use Rerun for v1 visualization because the xArm simulation stack already includes `RerunBridgeModule`, and `PointCloud2.to_rerun()` already exists. - -Alternatives considered: -- Viser: preferred long-term by the user, but would require more new infrastructure before verifying VGN geometry. -- Open3D-only debug windows: useful for local debugging, but less integrated with the existing DimOS visualization stack. - -### Render grasp candidates as simplified gripper wireframes - -Add `GraspCandidateArray.to_rerun()` or an equivalent visualization helper that logs simplified grippers with `rr.LineStrips3D`. Each candidate pose transforms local gripper geometry into world; jaw width controls finger separation; score controls color/ranking. - -Use fixed v1 visualization config: - -```python -GraspVisConfig: - max_grasps: int = 50 - top_k_highlight: int = 5 - finger_length_m: float = 0.055 - palm_depth_m: float = 0.035 - finger_thickness_m: float = 0.004 - default_width_m: float = 0.08 - min_score: float = 0.0 - top_color: tuple[int, int, int] = (0, 255, 80) - good_color: tuple[int, int, int] = (255, 220, 0) - low_color: tuple[int, int, int] = (255, 80, 0) -``` - -Alternatives considered: -- Pose arrows only: too thin; does not show jaw width or gripper envelope. -- Mesh grippers: prettier but more implementation work than needed for validation. - -### Keep the scene simple - -Use xArm7 with gripper, table, and one simple object such as a cube or mug-like cylinder with a distinct material. Avoid clutter for v1. - -Alternatives considered: -- Multiple cluttered objects: better stress test, but makes first failure diagnosis harder. -- No object, synthetic TSDF only: easier to run, but does not validate MuJoCo camera and scene visualization together. - -### Wrist camera uses existing MuJoCo camera configuration - -Use the existing wrist camera concept attached to `link7` through `MujocoSimModule` configuration. The demo should ensure depth and camera-info streams are enabled and that TF makes the wrist camera frame resolvable to `world`. - -Alternatives considered: -- Static external camera: simpler view, but does not validate the wrist-camera use case needed for real manipulation. - -### Demo is opt-in and separate from existing xArm perception sim - -Add a new demo blueprint/script rather than changing the default xArm perception simulation behavior. - -Alternatives considered: -- Modify `xarm_perception_sim` directly: simpler to find, but risks changing existing workflows and tests. - -## Risks / Trade-offs - -- MuJoCo asset/keyframe changes can break derived scene paths → keep demo asset additions minimal and verify the selected scene path exists. -- Wrist camera frame or depth intrinsics may not match reconstruction assumptions → add explicit Rerun visualization of pointcloud/TSDF/grasp frames and fail clearly when TF is missing. -- VGN may produce no grasps on a too-simple or badly scaled object → choose object size compatible with parallel-jaw gripper and show reconstruction status/model status. -- Rerun line-strip APIs may differ by version → keep visualization helper small and covered by a smoke test or import test. -- This change depends on the core reconstruction/VGN change → implement after or alongside `vgn-tsdf-scene-reconstruction` and avoid duplicating core types. - -## Migration Plan - -1. Add demo assets and visualization helpers behind opt-in demo names. -2. Wire a new demo blueprint/script without changing existing simulation blueprints. -3. Validate visually in Rerun and with smoke tests. -4. Rollback is removing the opt-in demo and helper wiring. - -## Open Questions - -- Exact object shape for the first committed scene: cube versus cylinder/mug-like object. -- Whether the demo should include scripted arm scan poses in v1 or rely on static wrist camera placement first. -- Whether TSDF surface visualization should be produced by the reconstruction module or a demo-only helper. diff --git a/openspec/changes/vgn-mujoco-grasp-demo/proposal.md b/openspec/changes/vgn-mujoco-grasp-demo/proposal.md deleted file mode 100644 index 65b1f6c048..0000000000 --- a/openspec/changes/vgn-mujoco-grasp-demo/proposal.md +++ /dev/null @@ -1,26 +0,0 @@ -## Why - -VGN grasp integration is hard to validate from unit tests alone because correctness depends on camera geometry, reconstruction frames, object placement, and visualization. A MuJoCo end-to-end demo gives a repeatable scene where generated grasps can be inspected against the robot, table, object, pointcloud, and TSDF-derived scene output. - -## What Changes - -- Add a simulation demo around xArm7 with a wrist depth camera, table, and one simple graspable object. -- Compose MuJoCo depth output, scene reconstruction, VGN grasp generation, and Rerun visualization into an opt-in demo blueprint/script. -- Visualize object/table scene pointcloud, optional TSDF surface/voxel points, and simplified gripper grasp candidates in a fixed Rerun layout. -- Add reusable Rerun visualization for `GraspCandidateArray` using simplified gripper wireframes. -- Keep v1 focused on visual/algorithm verification, not autonomous pick execution. - -## Capabilities - -### New Capabilities -- `vgn-mujoco-grasp-demo`: End-to-end simulated VGN grasp proposal demo with wrist depth camera and Rerun grasp visualization. - -### Modified Capabilities -- None. - -## Impact - -- Affected code areas: xArm simulation blueprints/demos, MuJoCo scene assets, Rerun visualization helpers, and grasp candidate visualization. -- Depends on the core `vgn-tsdf-scene-reconstruction` change for `TSDFGrid`, scene reconstruction, and VGN grasp candidate output. -- Uses existing MuJoCo camera stream support and existing Rerun bridge infrastructure where possible. -- Does not change real robot behavior or existing pick-and-place heuristics. diff --git a/openspec/changes/vgn-mujoco-grasp-demo/tasks.md b/openspec/changes/vgn-mujoco-grasp-demo/tasks.md deleted file mode 100644 index a2d033823a..0000000000 --- a/openspec/changes/vgn-mujoco-grasp-demo/tasks.md +++ /dev/null @@ -1,29 +0,0 @@ -## 1. Demo scene and simulation wiring - -- [ ] 1.1 Identify the existing xArm MuJoCo scene asset and wrist camera configuration used by `xarm_perception_sim`. -- [ ] 1.2 Add or select a minimal demo scene with xArm7, gripper, table, and one stable graspable object. -- [ ] 1.3 Ensure the simulated wrist camera publishes depth image and depth camera info streams for reconstruction. -- [ ] 1.4 Verify the wrist camera frame can be resolved to `world` through TF during the demo. - -## 2. Demo blueprint/script - -- [ ] 2.1 Create an opt-in VGN grasp demo blueprint or `demo_*` script that does not modify existing xArm simulation defaults. -- [ ] 2.2 Compose MuJoCo simulation, scene reconstruction, VGN grasp generation, and Rerun visualization modules. -- [ ] 2.3 Configure reconstruction workspace and output rate for the demo scene. -- [ ] 2.4 Add a simple demo flow that resets reconstruction, allows/causes depth integration, triggers grasp generation, and keeps visualization live. - -## 3. Grasp visualization - -- [ ] 3.1 Implement `GraspVisConfig` with the fixed v1 parameters from the design. -- [ ] 3.2 Add `GraspCandidateArray` Rerun visualization using simplified gripper `LineStrips3D` wireframes. -- [ ] 3.3 Map candidate jaw width to finger separation and score/rank to color or highlight style. -- [ ] 3.4 Clear or update visualization explicitly when no candidates are available to avoid stale grippers. -- [ ] 3.5 Add optional TSDF surface or voxel-point visualization under a stable Rerun entity path. - -## 4. Validation - -- [ ] 4.1 Add a lightweight smoke test for demo imports and blueprint/script construction. -- [ ] 4.2 Add a visualization helper test for gripper line geometry shape/count using synthetic grasp candidates. -- [ ] 4.3 Run the demo locally and confirm Rerun shows scene pointcloud, TSDF-derived surface/voxels, and grasp wireframes in world alignment. -- [ ] 4.4 Document the demo command and expected visual acceptance criteria. -- [ ] 4.5 Run `openspec status --change "vgn-mujoco-grasp-demo"` and verify artifacts remain apply-ready. diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/.openspec.yaml b/openspec/changes/vgn-tsdf-scene-reconstruction/.openspec.yaml deleted file mode 100644 index de73b342e8..0000000000 --- a/openspec/changes/vgn-tsdf-scene-reconstruction/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-25 diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/design.md b/openspec/changes/vgn-tsdf-scene-reconstruction/design.md deleted file mode 100644 index 335b2d0658..0000000000 --- a/openspec/changes/vgn-tsdf-scene-reconstruction/design.md +++ /dev/null @@ -1,92 +0,0 @@ -## Context - -DimOS already has depth-capable camera streams (`depth_image`, `depth_camera_info`, TF, and optional `pointcloud`) and a grasp-generation orchestration seam (`GraspingModule` + `GraspGenSpec`). What is missing is a native learned grasp backend and the scene reconstruction representation required by VGN. - -Official VGN reconstructs a TSDF from depth images, camera intrinsics, and camera poses, then runs inference on a grid shaped like `(1, 40, 40, 40)`. It does not build its canonical input from a pointcloud alone. DimOS should therefore model TSDF as a reusable scene product produced beside pointclouds, not as a private VGN adapter detail. - -## Goals / Non-Goals - -**Goals:** -- Introduce a reusable scene reconstruction module that consumes depth observations and publishes pointcloud, TSDF, and status outputs. -- Introduce a `TSDFGrid` message aligned with VGN/Open3D grid semantics. -- Introduce TSDF-native grasp generation with VGN as the first backend. -- Preserve existing pointcloud-oriented `GraspGenSpec` and existing heuristic pick behavior. -- Publish grasp outputs as scored gripper candidates in `world` frame, with PoseArray compatibility. -- Keep high-rate/large sensor and reconstruction payloads on streams; use RPC for control and lifecycle. - -**Non-Goals:** -- Executing robot picks or closing the gripper using VGN output. -- Supporting cluttered bin-picking quality in the first pass. -- Making Viser the primary visualization path in this change. -- Replacing `ObjectSceneRegistrationModule` or semantic object registration. -- Creating a pointcloud-only approximation as the primary VGN path. - -## Decisions - -### Scene reconstruction is generic, not VGN-private - -Create a `SceneReconstructionModule` that consumes depth streams and TF and publishes reconstructed scene products. VGN consumes the TSDF product. - -Alternatives considered: -- Put TSDF integration inside `VGNGraspGenModule`: simpler initially, but hides a reusable scene representation and couples reconstruction lifecycle to one model. -- Build TSDF from `PointCloud2`: easier to wire to existing pointcloud APIs, but loses camera-ray/free-space information needed for faithful TSDF integration. - -### TSDFGrid uses min-corner grid semantics - -`TSDFGrid` carries `distances` shaped `(1, X, Y, Z)`, `voxel_size`, `truncation_distance`, `origin`, `size`, `resolution`, optional `weights`, `frame_id`, and `ts`. Voxel `[0,0,0]` is located at the grid origin/min corner. Workspace RPCs can accept a user-friendly center and convert internally. - -Alternatives considered: -- Center-origin grids: friendlier for humans but adds conversion friction and ambiguity versus VGN/Open3D output. -- VGN-specific wrapper object: less reusable and harder to stream across DimOS modules. - -### Reconstruction continuously ingests but publishes at a fixed output rate - -The reconstruction module may ingest at camera rate or a configured throttle, but publishes `pointcloud`, `tsdf`, and `status` at `reconstruction_fps` such as 2 Hz. RPCs control lifecycle: reset, pause/resume integration, set workspace, snapshot, and status. - -Alternatives considered: -- One-shot RPC carrying depth/camera/TF payloads: easier to reason about for one request, but sends large pickled payloads through RPC and fights DimOS stream architecture. -- Fully manual start/stop-only reconstruction: faithful to scan workflows, but less useful as a general scene product producer. - -### TSDF grasp generation gets a new spec - -Add `TSDFGraspGenSpec.generate_grasps_from_tsdf(tsdf: TSDFGrid) -> GraspCandidateArray | None`. Keep the existing pointcloud `GraspGenSpec` unchanged for pointcloud-based backends. - -Alternatives considered: -- Change `GraspGenSpec` to accept TSDF: would break the existing seam and mix two different input contracts. -- Add optional pointcloud to TSDF generation spec: VGN does not require it for inference, so it should remain a sibling visualization/collision/debug output. - -### Grasp candidates are richer than PoseArray - -Introduce `GraspCandidate` and `GraspCandidateArray` with pose, jaw width, score, and optional id. Publish this as primary output and provide `to_pose_array()` compatibility. - -Alternatives considered: -- Only publish `PoseArray`: insufficient for VGN score, gripper width, visualization, ranking, and later execution. - -### VGN outputs world-frame candidates in v1 - -The VGN module transforms voxel/grid-frame grasps through `TSDFGrid.origin` and TF into `world`, then publishes `GraspCandidateArray.header.frame_id = "world"`. If the transform is unavailable, it returns no result and logs/raises a clear error. - -Alternatives considered: -- Configurable target frame: more flexible, but unnecessary for the first integration and creates more validation combinations. - -## Risks / Trade-offs - -- VGN import triggers ROS visualization side effects in non-ROS environments → lazy-import VGN inside the backend and isolate or shim visualization imports rather than importing `vgn.detection` at module import time. -- TSDF coordinate conventions can silently produce shifted grasps → require unit tests for voxel-to-world conversion and frame id/origin semantics. -- Reconstruction payloads are large → stream them and avoid RPC transport for TSDF/depth arrays. -- VGN model artifacts may not be present → fail with a clear backend status and installation/model-path message. -- Depth/camera/TF synchronization can be brittle → align by timestamps where available and expose reconstruction status with accepted/dropped frame counts. -- `CONTEXT.md` currently has merge conflict markers → do not update glossary/docs there until conflicts are resolved. - -## Migration Plan - -1. Add new message/spec/module types without changing existing grasp or pick paths. -2. Wire VGN into new opt-in blueprints/demos only. -3. Keep `GraspingModule` pointcloud flow and `PickAndPlaceModule` heuristic flow untouched. -4. Rollback is removing the opt-in modules/blueprints while leaving existing behavior unchanged. - -## Open Questions - -- Exact location/name for the reconstruction package: `dimos/perception/reconstruction` versus `dimos/navigation` or another namespace. -- Whether `TSDFGrid.weights` should be required immediately or optional until a downstream consumer needs confidence/observation counts. -- Exact model artifact discovery convention for VGN weights. diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/proposal.md b/openspec/changes/vgn-tsdf-scene-reconstruction/proposal.md deleted file mode 100644 index 81d993d554..0000000000 --- a/openspec/changes/vgn-tsdf-scene-reconstruction/proposal.md +++ /dev/null @@ -1,29 +0,0 @@ -## Why - -DimOS has an intended grasp-generation seam, but no working native learned grasp backend. VGN is now installable behind the `grasp` extra, and integrating it cleanly requires a depth-based scene reconstruction path because VGN consumes TSDF grids reconstructed from depth observations, not raw pointcloud-only inputs. - -## What Changes - -- Add a generic scene reconstruction capability that consumes depth images, depth camera calibration, and TF to maintain a reconstructed scene. -- Add a `TSDFGrid` message type aligned with Open3D/VGN grid semantics. -- Publish reconstructed scene products at a fixed reconstruction output rate, including `PointCloud2`, `TSDFGrid`, and reconstruction status. -- Add TSDF-native grasp generation contracts and a VGN-backed module that consumes latest TSDF data. -- Add grasp candidate message types carrying pose, jaw width, and score, with PoseArray compatibility for existing consumers. -- Output VGN grasp candidates in `world` frame for simple downstream use. -- Keep high-rate sensor data on streams; use RPC for reconstruction lifecycle/control only. - -## Capabilities - -### New Capabilities -- `scene-reconstruction`: Generic depth-based scene reconstruction that produces pointcloud and TSDF scene products from depth observations. -- `tsdf-grasp-generation`: TSDF-native grasp generation that converts reconstructed TSDF grids into scored world-frame grasp candidates. - -### Modified Capabilities -- None. - -## Impact - -- Affected code areas: `dimos/msgs`, `dimos/perception` or reconstruction modules, `dimos/manipulation/grasping`, and xArm/manipulation blueprints that opt into the new modules. -- New runtime path depends on the existing `grasp` optional extra for VGN and existing depth camera streams. -- Existing `GraspGenSpec` and pointcloud-based grasp paths remain available; this change adds a TSDF-native path rather than replacing them. -- Sensor payloads remain stream-based to avoid large pickled RPC requests. diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/tasks.md b/openspec/changes/vgn-tsdf-scene-reconstruction/tasks.md deleted file mode 100644 index c97c686a18..0000000000 --- a/openspec/changes/vgn-tsdf-scene-reconstruction/tasks.md +++ /dev/null @@ -1,34 +0,0 @@ -## 1. Message and spec contracts - -- [ ] 1.1 Add `TSDFGrid` message type with frame id, timestamp, distances, voxel size, truncation distance, origin transform, size, resolution, and optional weights. -- [ ] 1.2 Add `ReconstructionStatus` message type with active/paused state, workspace metadata, accepted frame count, dropped frame count, latest integration timestamp, and latest error/status text. -- [ ] 1.3 Add `GraspCandidate` and `GraspCandidateArray` message types with pose, jaw width, score, optional id, header, and `to_pose_array()` compatibility. -- [ ] 1.4 Add or update serialization/encode/decode helpers and type annotations for new messages. -- [ ] 1.5 Add unit tests for `TSDFGrid` shape metadata, voxel-to-frame coordinate semantics, and `GraspCandidateArray.to_pose_array()` ordering. - -## 2. Scene reconstruction module - -- [ ] 2.1 Create a generic `SceneReconstructionModule` consuming `depth_image` and `depth_camera_info` streams plus TF lookups. -- [ ] 2.2 Implement workspace configuration using user-facing frame, center, and size while storing TSDF grid origin as the min-corner frame transform. -- [ ] 2.3 Implement continuous depth integration with configurable ingest throttle and fixed `reconstruction_fps` publication. -- [ ] 2.4 Publish `pointcloud`, `tsdf`, and `status` streams derived from the same accepted depth observations. -- [ ] 2.5 Add RPC controls: `reset_scene`, `pause_integration`, `resume_integration`, `set_workspace`, `snapshot_scene`, and `get_reconstruction_status`. -- [ ] 2.6 Track and publish dropped-frame status when camera info or TF is missing or stale. -- [ ] 2.7 Add unit tests for lifecycle controls, publication throttling, missing-transform handling, and workspace origin conversion. - -## 3. TSDF grasp generation module - -- [ ] 3.1 Add `TSDFGraspGenSpec.generate_grasps_from_tsdf(tsdf: TSDFGrid) -> GraspCandidateArray | None`. -- [ ] 3.2 Implement `VGNGraspGenModule` with lazy VGN imports and clear errors for missing optional dependency or model weights. -- [ ] 3.3 Subscribe to latest `TSDFGrid` stream and expose RPC generation from latest TSDF. -- [ ] 3.4 Convert VGN voxel-coordinate predictions into `TSDFGrid` frame poses using voxel size and grid origin semantics. -- [ ] 3.5 Transform grasp candidates into `world` frame and report a clear failure when the transform is unavailable. -- [ ] 3.6 Publish primary `GraspCandidateArray` output and optional PoseArray compatibility output. -- [ ] 3.7 Add unit tests for lazy import failure, VGN output conversion, world-frame transform behavior, and no-pointcloud-required inference path. - -## 4. Wiring and validation - -- [ ] 4.1 Add opt-in blueprint wiring for depth camera → scene reconstruction → VGN grasp generation without changing existing pick-and-place behavior. -- [ ] 4.2 Add smoke tests or lightweight integration tests using synthetic TSDF data and stubbed VGN outputs. -- [ ] 4.3 Run targeted pytest for new messages, reconstruction module, and grasp generation module. -- [ ] 4.4 Run `openspec status --change "vgn-tsdf-scene-reconstruction"` and verify artifacts remain apply-ready. diff --git a/openspec/specs/registered-object-targeting/spec.md b/openspec/specs/registered-object-targeting/spec.md new file mode 100644 index 0000000000..64c504bea6 --- /dev/null +++ b/openspec/specs/registered-object-targeting/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Registered object metadata contract +The system SHALL provide a typed `RegisteredObject` contract for cross-module object metadata used to identify a Grasp target. + +#### Scenario: Registered object includes target bounds metadata +- **WHEN** object scene registration returns a registered object +- **THEN** the record includes object id, display name if available, center, size, frame id, and timestamp + +#### Scenario: Registered object is typed +- **WHEN** a module consumes registered object metadata +- **THEN** it receives a typed `RegisteredObject` value rather than an unstructured dictionary + +### Requirement: Object lookup by object id +The object scene registration capability SHALL provide a typed lookup by stable object id. + +#### Scenario: Object id resolves +- **WHEN** a caller requests a registered object by an object id that is known to object scene registration +- **THEN** the system returns the matching `RegisteredObject` metadata + +#### Scenario: Object id is unknown +- **WHEN** a caller requests a registered object by an unknown object id +- **THEN** the system returns no object and reports a clear no-target outcome to the caller + +### Requirement: User-facing object grasp orchestration +The grasp orchestration layer SHALL expose a user-facing operation that generates grasps for a non-ambiguous object id. + +#### Scenario: Generate grasps for object id +- **WHEN** a caller requests grasp generation for an object id and that id resolves to a registered object +- **THEN** the grasp orchestration layer passes the registered object target bounds to the TSDF grasp generator + +#### Scenario: Preserve pointcloud grasping compatibility +- **WHEN** existing pointcloud grasp APIs are called by object name or object id +- **THEN** their existing behavior remains available and is not replaced by target-conditioned TSDF grasping diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md b/openspec/specs/scene-reconstruction/spec.md similarity index 100% rename from openspec/changes/vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md rename to openspec/specs/scene-reconstruction/spec.md diff --git a/openspec/specs/target-conditioned-tsdf-grasp-generation/spec.md b/openspec/specs/target-conditioned-tsdf-grasp-generation/spec.md new file mode 100644 index 0000000000..2047bb7ed5 --- /dev/null +++ b/openspec/specs/target-conditioned-tsdf-grasp-generation/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Target-bounds TSDF grasp generation +The TSDF grasp generation capability SHALL support generating grasp candidates for a target region described by world-frame Target bounds. + +#### Scenario: Generate from latest TSDF and target bounds +- **WHEN** the TSDF grasp generator has a latest TSDF and receives target center, target size, and cushion distance +- **THEN** it generates a `GraspCandidateArray` for geometry inside the cushioned target bounds or returns a clear no-result outcome + +#### Scenario: No latest TSDF available +- **WHEN** target-bounds grasp generation is requested before any TSDF has been received +- **THEN** the generator does not publish stale candidates and reports that no TSDF is available + +### Requirement: Grasp generation decoupled from object identity +The low-level TSDF grasp generation spec SHALL accept Target bounds and SHALL NOT require object ids or object database access. + +#### Scenario: Caller passes bounds instead of object id +- **WHEN** a caller requests target-conditioned TSDF grasp generation +- **THEN** the request contains target center, target size, and cushion rather than an object id or `RegisteredObject` + +### Requirement: Target-masked TSDF preprocessing +The VGN grasp generator SHALL construct a Target-masked TSDF internally before target-conditioned inference. + +#### Scenario: Suppress outside target bounds +- **WHEN** a Target-masked TSDF is constructed from a full scene TSDF +- **THEN** voxels outside the target bounds expanded by the cushion are suppressed so VGN does not select grasps on outside clutter + +#### Scenario: Preserve target cushion +- **WHEN** target bounds are expanded by the configured cushion +- **THEN** voxels inside the cushioned bounds remain available for VGN inference + +#### Scenario: Original TSDF remains unchanged +- **WHEN** target-conditioned generation masks a TSDF for inference +- **THEN** the module does not mutate the stored full-scene latest TSDF + +### Requirement: Target frame handling +The target-conditioned TSDF grasp generator SHALL transform Target bounds into the TSDF grid frame before masking when frames differ. + +#### Scenario: Target transform available +- **WHEN** target bounds are provided in a frame that can be transformed to the TSDF frame +- **THEN** the generator masks the TSDF using the transformed bounds and publishes candidates in the configured output frame + +#### Scenario: Target transform unavailable +- **WHEN** target bounds cannot be transformed into the TSDF frame +- **THEN** the generator reports a clear transform failure and does not publish misleading candidates + +### Requirement: Workspace-level generation preserved +The system SHALL preserve target-agnostic workspace TSDF grasp generation. + +#### Scenario: Workspace generation still available +- **WHEN** a caller invokes workspace-level `generate_grasps_from_tsdf` +- **THEN** VGN inference runs on the provided TSDF without applying object target bounds diff --git a/openspec/specs/target-conditioned-vgn-demo/spec.md b/openspec/specs/target-conditioned-vgn-demo/spec.md new file mode 100644 index 0000000000..d7a63faac7 --- /dev/null +++ b/openspec/specs/target-conditioned-vgn-demo/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Target-conditioned VGN demo flow +The opt-in VGN MuJoCo demo SHALL exercise object-id-targeted grasp generation rather than only workspace-level grasp generation. + +#### Scenario: Demo observation pose sees object +- **WHEN** the target-conditioned demo starts reconstruction +- **THEN** the wrist camera has been moved or initialized to a pose where the configured table object is visible to the depth camera + +#### Scenario: Demo resolves a registered object +- **WHEN** the target-conditioned demo flow runs after object perception has registered an object +- **THEN** it resolves a registered object id into Target bounds before requesting VGN grasps + +#### Scenario: Demo auto-selects configured target only +- **WHEN** object registration creates runtime registered objects after the demo starts +- **THEN** the demo selects only a registered object matching the configured demo target prompt or name rather than an arbitrary workspace surface + +#### Scenario: Demo uses generated runtime object id +- **WHEN** the demo selects a registered object matching the configured target prompt or name +- **THEN** it uses that runtime-generated object id when calling the user-facing object grasp API + +#### Scenario: Demo triggers target-conditioned generation +- **WHEN** reconstruction has produced a latest TSDF and the demo has Target bounds +- **THEN** the demo calls the target-conditioned TSDF grasp generation path + +### Requirement: Demo remains opt-in +The target-conditioned VGN demo SHALL remain separate from existing xArm simulation defaults. + +#### Scenario: Existing simulation unchanged +- **WHEN** existing xArm simulation blueprints are launched +- **THEN** they do not start target-conditioned VGN grasping unless the opt-in demo is selected + +### Requirement: Demo target failure reporting +The demo SHALL report clear no-target outcomes when object selection or target-conditioned generation cannot proceed. + +#### Scenario: No registered object available +- **WHEN** the demo cannot resolve a registered object for grasping +- **THEN** it reports a clear no-target outcome instead of running workspace-level VGN as if it were object-targeted + +#### Scenario: Configured target not visible +- **WHEN** the configured demo target prompt or name does not produce a registered object after the observation window +- **THEN** the demo reports the missing target and does not publish target-conditioned candidates + +#### Scenario: Target-conditioned generation returns no candidates +- **WHEN** VGN returns no candidates for the Target-masked TSDF +- **THEN** the demo clears stale grasp visualization and reports the no-candidate outcome + +### Requirement: Target-conditioned visualization context +The demo SHALL make it possible to visually confirm that generated grasps are associated with the selected target region. + +#### Scenario: Target bounds visible or inspectable +- **WHEN** the demo produces target-conditioned grasp candidates +- **THEN** the selected Target bounds are visible in Rerun under a stable entity path alongside grasp candidates + +#### Scenario: Stable target-conditioned entity layout +- **WHEN** the demo logs target-conditioned visualization data +- **THEN** it uses stable entity paths under `world/`, including `world/scene_pointcloud`, `world/tsdf_surface`, `world/grasp_target_bounds`, and `world/grasp_candidates` + +#### Scenario: Selected target metadata visible +- **WHEN** the demo selects a registered object for grasping +- **THEN** the selected object id and name are visible in logs or visualization metadata for debugging diff --git a/openspec/changes/vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md b/openspec/specs/tsdf-grasp-generation/spec.md similarity index 100% rename from openspec/changes/vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md rename to openspec/specs/tsdf-grasp-generation/spec.md diff --git a/openspec/changes/vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md b/openspec/specs/vgn-mujoco-grasp-demo/spec.md similarity index 100% rename from openspec/changes/vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md rename to openspec/specs/vgn-mujoco-grasp-demo/spec.md From a0faf73ad3943fa4bf8e5f9b9c7ecc8783920a53 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 01:18:43 -0700 Subject: [PATCH 081/110] spec: add archive --- .gitignore | 3 - .../.openspec.yaml | 2 + .../design.md | 122 ++++++++++++++++++ .../proposal.md | 29 +++++ .../specs/registered-object-targeting/spec.md | 34 +++++ .../spec.md | 52 ++++++++ .../specs/target-conditioned-vgn-demo/spec.md | 61 +++++++++ .../tasks.md | 43 ++++++ .../.openspec.yaml | 2 + .../design.md | 97 ++++++++++++++ .../proposal.md | 26 ++++ .../specs/vgn-mujoco-grasp-demo/spec.md | 60 +++++++++ .../2026-06-26-vgn-mujoco-grasp-demo/tasks.md | 29 +++++ .../.openspec.yaml | 2 + .../design.md | 92 +++++++++++++ .../proposal.md | 29 +++++ .../specs/scene-reconstruction/spec.md | 53 ++++++++ .../specs/tsdf-grasp-generation/spec.md | 52 ++++++++ .../tasks.md | 34 +++++ 19 files changed, 819 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/design.md create mode 100644 openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/proposal.md create mode 100644 openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/registered-object-targeting/spec.md create mode 100644 openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/target-conditioned-tsdf-grasp-generation/spec.md create mode 100644 openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/target-conditioned-vgn-demo/spec.md create mode 100644 openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/tasks.md create mode 100644 openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/design.md create mode 100644 openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/proposal.md create mode 100644 openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md create mode 100644 openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/tasks.md create mode 100644 openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/design.md create mode 100644 openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/proposal.md create mode 100644 openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md create mode 100644 openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md create mode 100644 openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/tasks.md diff --git a/.gitignore b/.gitignore index be5fad50c8..1c219cbfbd 100644 --- a/.gitignore +++ b/.gitignore @@ -100,8 +100,5 @@ recording*.db /misc/fresh-ubuntu-tests/cache -# openspec -/openspec/changes/archive/ - # Deepwork local progress state .slim/deepwork/ diff --git a/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/.openspec.yaml b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/.openspec.yaml new file mode 100644 index 0000000000..578bd54974 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-26 diff --git a/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/design.md b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/design.md new file mode 100644 index 0000000000..b1390d60ad --- /dev/null +++ b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/design.md @@ -0,0 +1,122 @@ +## Context + +The current VGN grasp path is intentionally workspace-level: `SceneReconstructionModule` publishes a full scene TSDF, and `VGNGraspGenModule` generates candidates for whatever graspable geometry exists in that TSDF. That matches upstream VGN clutter-removal semantics, but it does not answer commands like “grasp the cup” when multiple objects or surfaces are present. + +DimOS already has a user-facing grasp orchestration layer in `GraspingModule`, plus `ObjectSceneRegistrationModule` for perception-backed object lookup. The existing pointcloud flow can resolve an `object_id` into an object pointcloud, while the new TSDF/VGN flow only knows about a latest TSDF. The new target-conditioned path adds one layer between these concepts: `GraspingModule` resolves a non-ambiguous registered object into world-frame target bounds, and the VGN module masks its latest TSDF to those bounds before inference. + +The glossary terms for this design are: Grasp target, Object id, Registered object, Target bounds, and Target-masked TSDF. + +## Goals / Non-Goals + +**Goals:** + +- Add a typed `RegisteredObject` metadata contract for object-id-based grasp target resolution. +- Add an object registration lookup that returns object metadata by object id without forcing callers to reconstruct bounds from pointclouds. +- Add a user-facing `GraspingModule` API for generating grasps for a specific object id. +- Keep low-level grasp generation decoupled from `ObjectDB` and object ids by passing target bounds, not object records. +- Build Target-masked TSDF preprocessing inside `VGNGraspGenModule` so the generic reconstruction module remains full-scene and reusable. +- Preserve existing workspace-level TSDF grasp generation and pointcloud grasping behavior. +- Update the opt-in VGN MuJoCo demo to exercise the object-id-targeted flow with deterministic object selection, target visualization, and a wrist-camera pose that sees the object. + +**Non-Goals:** + +- Do not make VGN semantically understand object classes or object ids. +- Do not make `SceneReconstructionModule` produce object-specific TSDFs. +- Do not replace object detection, tracking, or selection policy in this change. +- Do not require exact object meshes or segmentation masks for v1 target conditioning. +- Do not remove the target-agnostic workspace VGN path. + +## Decisions + +### 1. User-facing object API lives in `GraspingModule` + +`GraspingModule` will expose a user-facing `generate_grasps_for_object(object_id: str, cushion_m: float = 0.03)`-style API. It will resolve the object id through object scene registration, extract target bounds, and call the TSDF grasp generator with those bounds. + +Alternatives considered: + +- Put object id handling directly in `VGNGraspGenModule`: rejected because that couples grasp backends to `ObjectDB` semantics and makes non-VGN grasp generators inherit perception-specific concerns. +- Put target selection in `SceneReconstructionModule`: rejected because scene reconstruction should remain a generic full-scene producer. + +### 2. Add typed `RegisteredObject` metadata lookup + +Object scene registration will provide `get_object_by_object_id(object_id: str) -> RegisteredObject | None`. `RegisteredObject` will live under `dimos/msgs/perception_msgs/RegisteredObject.py` and carry object id, name, center, size, frame id, and timestamp. + +The contract intentionally carries rough world-frame spatial metadata rather than full pointclouds. Target-conditioned VGN only needs a rough attention region; exact geometry still comes from the TSDF. + +Alternatives considered: + +- Use a dict: rejected because this crosses module boundaries and should remain typed/testable. +- Recompute bounds from object pointcloud every time: rejected because a first-class metadata lookup is cheaper, clearer, and avoids duplicating object-bound semantics in grasp orchestration. + +### 3. Low-level TSDF grasp API accepts target bounds + +`TSDFGraspGenSpec` will gain a target-bounds method such as `generate_grasps_for_target_bounds(target_center: Vector3, target_size: Vector3, cushion_m: float = 0.03) -> GraspCandidateArray | None`. The VGN module owns latest TSDF state, so the user-facing object flow does not need to pass a large TSDF through RPC. + +An explicit-TSDF variant may be added for tests and offline callers if useful, but the primary live path should use the latest streamed TSDF. + +Alternatives considered: + +- Pass `RegisteredObject` to graspgen: rejected because grasp generation should not depend on perception record shape. +- Pass object id to graspgen: rejected because object id is a user/perception identity concern, not a low-level grasp generation contract. + +### 4. Target-masked TSDF is internal to `VGNGraspGenModule` + +The VGN module will construct a Target-masked TSDF by suppressing voxels outside target bounds expanded by `cushion_m`. Suppressed voxels should be set to free/unknown values that prevent VGN from selecting grasps on outside clutter while preserving target geometry inside the cushion. + +The original full TSDF is not mutated; masking is a per-request preprocessing step before VGN inference. + +Alternatives considered: + +- Reconstruct a target-only TSDF upstream: rejected because it fragments reconstruction and makes target selection a reconstruction concern. +- Crop from object pointcloud and reconstruct a new TSDF: rejected for v1 because bbox conditioning is faster and rough bounds are sufficient for VGN to find actual surfaces. + +### 5. Demo selection may be automatic only for convenience + +The real user-facing API should use explicit object ids after perception has created them. The demo cannot take an object id as a normal startup parameter because the object id is generated only after the blueprint starts, perception observes the scene, and object registration creates runtime records. Therefore the demo is configured by target description, not object id. + +Demo behavior should be deterministic: + +1. Move or initialize the xArm end effector/wrist camera to a known pre-grasp observation pose that points at the table object before collecting the reconstruction window. +2. Run perception with a configured target prompt/name for the demo object, such as `cup` when using the existing xArm scene. +3. Wait for `ObjectSceneRegistrationModule` to register one or more objects matching the configured target prompt/name. +4. Select a matching runtime registered object deterministically, then use that generated object id for the user-facing `GraspingModule.generate_grasps_for_object(...)` call. +5. If no matching registered object appears before timeout, stop with a clear no-target outcome instead of silently falling back to workspace-level VGN. +6. Resolve that registered object to Target bounds and call the target-conditioned VGN path. + +### 6. Demo visualization must show why the grasp is target-conditioned + +The demo should visualize enough context to answer “what object did it choose?” It should keep the existing scene products and grasp wireframes, and add target-specific context: + +- full scene pointcloud under `world/scene_pointcloud`; +- TSDF surface or voxel points under `world/tsdf_surface`; +- selected Target bounds under `world/grasp_target_bounds`; +- grasp candidates under `world/grasp_candidates`; +- optionally a Target-masked TSDF debug view under `world/target_masked_tsdf` if useful during validation. + +The Target bounds visualization is required for v1. The Target-masked TSDF visualization is optional because it can be heavier and the bounds plus candidates are enough to validate target association. + +## Risks / Trade-offs + +- Target bounds too tight → target surface may be suppressed. Mitigation: default cushion and tests around mask inclusion at bounds edges. +- Target bounds too loose → nearby clutter may remain graspable. Mitigation: make cushion explicit/configurable and document that target bounds are rough attention, not exact segmentation. +- Object frame mismatch → grasps may be generated around the wrong region. Mitigation: require target bounds in a resolvable frame and transform to TSDF frame before masking. +- No latest TSDF available → user-facing object grasp request cannot run. Mitigation: return a clear no-result/status and do not publish stale candidates. +- Ambiguous demo target prompt/name → wrong runtime object selected. Mitigation: select deterministically from registered matches using documented tie-breakers such as highest confidence, then nearest to workspace center; log and visualize selected object id/name and Target bounds. +- Wrist camera default pose does not see the object → reconstruction contains no useful target geometry. Mitigation: add a demo observation pose or keyframe that points the end effector/wrist camera at the table object before integration. +- Demo convenience auto-selection could hide targeting errors → wrong object appears targeted. Mitigation: configure by target prompt/name, wait for runtime registration, select only among matching registered objects, log/visualize selected object id/name and Target bounds, and do not fall back to workspace-level VGN when no registered target is available. +- Active previous OpenSpec changes are not archived yet → this proposal is expressed as additive capabilities rather than modifying main specs until archive time. + +## Migration Plan + +1. Add typed `RegisteredObject` and object registration lookup while preserving existing pointcloud lookup methods. +2. Extend TSDF grasp spec and VGN module with target-bounds generation and Target-masked TSDF preprocessing. +3. Add `GraspingModule.generate_grasps_for_object` orchestration using object id → registered object → target bounds. +4. Update the opt-in demo to move/initialize the wrist camera to an observation pose, include object registration/selection, visualize selected Target bounds, and call the targeted API. +5. Keep existing workspace-level TSDF and pointcloud grasp APIs as compatibility paths. + +Rollback is straightforward because all APIs are additive; existing workspace-level behavior remains available. + +## Open Questions + +- Which exact xArm joint pose/keyframe should be used as the default wrist-camera observation pose for the existing table object? +- Should the default demo target prompt/name be `cup`, or should it target the easiest visible object in the current scene after the observation pose is chosen? diff --git a/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/proposal.md b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/proposal.md new file mode 100644 index 0000000000..b96075ad90 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/proposal.md @@ -0,0 +1,29 @@ +## Why + +The current VGN demo and TSDF grasp generator match upstream VGN semantics: they generate grasps for whatever geometry is present in the reconstructed workspace. For robot instructions such as “grasp the cup,” the system needs a non-ambiguous object-targeted path so VGN proposes grasps for the selected registered object rather than any nearby surface or clutter. + +## What Changes + +- Add a small typed `RegisteredObject` contract for cross-module object metadata: object id, name, center, size, frame id, and timestamp. +- Extend object scene registration with a typed lookup by stable object id. +- Extend the user-facing grasping orchestration API with object-id-based target selection. +- Extend TSDF grasp generation with target-bounds-conditioned generation while keeping object ids out of the grasp generation spec. +- Add target-masked TSDF preprocessing inside the VGN grasp generator using world-frame axis-aligned target bounds plus cushion. +- Update the VGN MuJoCo demo flow to move/initialize the wrist camera so the object is visible, resolve a registered object deterministically, visualize the selected target bounds, and generate target-conditioned grasps for that object. + +## Capabilities + +### New Capabilities +- `registered-object-targeting`: Cross-module registered object metadata lookup and object-id-based grasp target resolution. +- `target-conditioned-tsdf-grasp-generation`: Generate TSDF-native grasp candidates from a latest scene TSDF constrained by target bounds. +- `target-conditioned-vgn-demo`: Exercise object-id-targeted VGN grasp generation in the opt-in MuJoCo demo. + +### Modified Capabilities + + +## Impact + +- Affected contracts: `ObjectSceneRegistrationSpec`, `TSDFGraspGenSpec`, new `RegisteredObject` message contract. +- Affected modules: `ObjectSceneRegistrationModule`, `GraspingModule`, `VGNGraspGenModule`, and the opt-in `vgn-mujoco-grasp-demo` blueprint or demo helper flow. +- Existing pointcloud grasping and workspace-level `generate_grasps_from_tsdf(tsdf)` behavior remain available. +- No new external dependency is expected. diff --git a/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/registered-object-targeting/spec.md b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/registered-object-targeting/spec.md new file mode 100644 index 0000000000..64c504bea6 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/registered-object-targeting/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Registered object metadata contract +The system SHALL provide a typed `RegisteredObject` contract for cross-module object metadata used to identify a Grasp target. + +#### Scenario: Registered object includes target bounds metadata +- **WHEN** object scene registration returns a registered object +- **THEN** the record includes object id, display name if available, center, size, frame id, and timestamp + +#### Scenario: Registered object is typed +- **WHEN** a module consumes registered object metadata +- **THEN** it receives a typed `RegisteredObject` value rather than an unstructured dictionary + +### Requirement: Object lookup by object id +The object scene registration capability SHALL provide a typed lookup by stable object id. + +#### Scenario: Object id resolves +- **WHEN** a caller requests a registered object by an object id that is known to object scene registration +- **THEN** the system returns the matching `RegisteredObject` metadata + +#### Scenario: Object id is unknown +- **WHEN** a caller requests a registered object by an unknown object id +- **THEN** the system returns no object and reports a clear no-target outcome to the caller + +### Requirement: User-facing object grasp orchestration +The grasp orchestration layer SHALL expose a user-facing operation that generates grasps for a non-ambiguous object id. + +#### Scenario: Generate grasps for object id +- **WHEN** a caller requests grasp generation for an object id and that id resolves to a registered object +- **THEN** the grasp orchestration layer passes the registered object target bounds to the TSDF grasp generator + +#### Scenario: Preserve pointcloud grasping compatibility +- **WHEN** existing pointcloud grasp APIs are called by object name or object id +- **THEN** their existing behavior remains available and is not replaced by target-conditioned TSDF grasping diff --git a/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/target-conditioned-tsdf-grasp-generation/spec.md b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/target-conditioned-tsdf-grasp-generation/spec.md new file mode 100644 index 0000000000..2047bb7ed5 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/target-conditioned-tsdf-grasp-generation/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Target-bounds TSDF grasp generation +The TSDF grasp generation capability SHALL support generating grasp candidates for a target region described by world-frame Target bounds. + +#### Scenario: Generate from latest TSDF and target bounds +- **WHEN** the TSDF grasp generator has a latest TSDF and receives target center, target size, and cushion distance +- **THEN** it generates a `GraspCandidateArray` for geometry inside the cushioned target bounds or returns a clear no-result outcome + +#### Scenario: No latest TSDF available +- **WHEN** target-bounds grasp generation is requested before any TSDF has been received +- **THEN** the generator does not publish stale candidates and reports that no TSDF is available + +### Requirement: Grasp generation decoupled from object identity +The low-level TSDF grasp generation spec SHALL accept Target bounds and SHALL NOT require object ids or object database access. + +#### Scenario: Caller passes bounds instead of object id +- **WHEN** a caller requests target-conditioned TSDF grasp generation +- **THEN** the request contains target center, target size, and cushion rather than an object id or `RegisteredObject` + +### Requirement: Target-masked TSDF preprocessing +The VGN grasp generator SHALL construct a Target-masked TSDF internally before target-conditioned inference. + +#### Scenario: Suppress outside target bounds +- **WHEN** a Target-masked TSDF is constructed from a full scene TSDF +- **THEN** voxels outside the target bounds expanded by the cushion are suppressed so VGN does not select grasps on outside clutter + +#### Scenario: Preserve target cushion +- **WHEN** target bounds are expanded by the configured cushion +- **THEN** voxels inside the cushioned bounds remain available for VGN inference + +#### Scenario: Original TSDF remains unchanged +- **WHEN** target-conditioned generation masks a TSDF for inference +- **THEN** the module does not mutate the stored full-scene latest TSDF + +### Requirement: Target frame handling +The target-conditioned TSDF grasp generator SHALL transform Target bounds into the TSDF grid frame before masking when frames differ. + +#### Scenario: Target transform available +- **WHEN** target bounds are provided in a frame that can be transformed to the TSDF frame +- **THEN** the generator masks the TSDF using the transformed bounds and publishes candidates in the configured output frame + +#### Scenario: Target transform unavailable +- **WHEN** target bounds cannot be transformed into the TSDF frame +- **THEN** the generator reports a clear transform failure and does not publish misleading candidates + +### Requirement: Workspace-level generation preserved +The system SHALL preserve target-agnostic workspace TSDF grasp generation. + +#### Scenario: Workspace generation still available +- **WHEN** a caller invokes workspace-level `generate_grasps_from_tsdf` +- **THEN** VGN inference runs on the provided TSDF without applying object target bounds diff --git a/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/target-conditioned-vgn-demo/spec.md b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/target-conditioned-vgn-demo/spec.md new file mode 100644 index 0000000000..d7a63faac7 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/specs/target-conditioned-vgn-demo/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Target-conditioned VGN demo flow +The opt-in VGN MuJoCo demo SHALL exercise object-id-targeted grasp generation rather than only workspace-level grasp generation. + +#### Scenario: Demo observation pose sees object +- **WHEN** the target-conditioned demo starts reconstruction +- **THEN** the wrist camera has been moved or initialized to a pose where the configured table object is visible to the depth camera + +#### Scenario: Demo resolves a registered object +- **WHEN** the target-conditioned demo flow runs after object perception has registered an object +- **THEN** it resolves a registered object id into Target bounds before requesting VGN grasps + +#### Scenario: Demo auto-selects configured target only +- **WHEN** object registration creates runtime registered objects after the demo starts +- **THEN** the demo selects only a registered object matching the configured demo target prompt or name rather than an arbitrary workspace surface + +#### Scenario: Demo uses generated runtime object id +- **WHEN** the demo selects a registered object matching the configured target prompt or name +- **THEN** it uses that runtime-generated object id when calling the user-facing object grasp API + +#### Scenario: Demo triggers target-conditioned generation +- **WHEN** reconstruction has produced a latest TSDF and the demo has Target bounds +- **THEN** the demo calls the target-conditioned TSDF grasp generation path + +### Requirement: Demo remains opt-in +The target-conditioned VGN demo SHALL remain separate from existing xArm simulation defaults. + +#### Scenario: Existing simulation unchanged +- **WHEN** existing xArm simulation blueprints are launched +- **THEN** they do not start target-conditioned VGN grasping unless the opt-in demo is selected + +### Requirement: Demo target failure reporting +The demo SHALL report clear no-target outcomes when object selection or target-conditioned generation cannot proceed. + +#### Scenario: No registered object available +- **WHEN** the demo cannot resolve a registered object for grasping +- **THEN** it reports a clear no-target outcome instead of running workspace-level VGN as if it were object-targeted + +#### Scenario: Configured target not visible +- **WHEN** the configured demo target prompt or name does not produce a registered object after the observation window +- **THEN** the demo reports the missing target and does not publish target-conditioned candidates + +#### Scenario: Target-conditioned generation returns no candidates +- **WHEN** VGN returns no candidates for the Target-masked TSDF +- **THEN** the demo clears stale grasp visualization and reports the no-candidate outcome + +### Requirement: Target-conditioned visualization context +The demo SHALL make it possible to visually confirm that generated grasps are associated with the selected target region. + +#### Scenario: Target bounds visible or inspectable +- **WHEN** the demo produces target-conditioned grasp candidates +- **THEN** the selected Target bounds are visible in Rerun under a stable entity path alongside grasp candidates + +#### Scenario: Stable target-conditioned entity layout +- **WHEN** the demo logs target-conditioned visualization data +- **THEN** it uses stable entity paths under `world/`, including `world/scene_pointcloud`, `world/tsdf_surface`, `world/grasp_target_bounds`, and `world/grasp_candidates` + +#### Scenario: Selected target metadata visible +- **WHEN** the demo selects a registered object for grasping +- **THEN** the selected object id and name are visible in logs or visualization metadata for debugging diff --git a/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/tasks.md b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/tasks.md new file mode 100644 index 0000000000..d0d0ff4131 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-target-conditioned-vgn-grasping/tasks.md @@ -0,0 +1,43 @@ +## 1. Registered object targeting contracts + +- [x] 1.1 Add `RegisteredObject` message type under `dimos/msgs/perception_msgs/` with object id, name, center, size, frame id, and timestamp. +- [x] 1.2 Add serialization and type tests for `RegisteredObject` metadata round-trip and target bounds fields. +- [x] 1.3 Extend `ObjectSceneRegistrationSpec` with `get_object_by_object_id(object_id: str) -> RegisteredObject | None`. +- [x] 1.4 Implement the typed object-id metadata lookup in `ObjectSceneRegistrationModule` without removing existing pointcloud lookup methods. +- [x] 1.5 Add object registration tests for known object id, unknown object id, and preservation of existing pointcloud lookup behavior. + +## 2. Target-conditioned TSDF grasp generation + +- [x] 2.1 Extend `TSDFGraspGenSpec` with a target-bounds grasp generation method that accepts target center, target size, and cushion without object ids. +- [x] 2.2 Implement latest-TSDF target-bounds generation in `VGNGraspGenModule` with clear no-TSDF and no-candidate outcomes. +- [x] 2.3 Implement Target-masked TSDF preprocessing inside `VGNGraspGenModule` using target bounds expanded by cushion. +- [x] 2.4 Ensure target masking does not mutate the stored full-scene latest TSDF. +- [x] 2.5 Transform Target bounds into the TSDF grid frame before masking when target and TSDF frames differ. +- [x] 2.6 Preserve existing workspace-level `generate_grasps_from_tsdf(tsdf)` behavior without target masking. +- [x] 2.7 Add unit tests for mask inclusion/exclusion, cushion behavior, no-latest-TSDF handling, transform failure, transformed bounds, and workspace-generation compatibility. + +## 3. User-facing grasp orchestration + +- [x] 3.1 Add `GraspingModule.generate_grasps_for_object(object_id: str, cushion_m: float = 0.03)` or equivalent user-facing API. +- [x] 3.2 Wire `GraspingModule` to resolve `RegisteredObject` metadata by object id before calling target-bounds TSDF grasp generation. +- [x] 3.3 Return clear user-facing messages for unknown object id, missing latest TSDF, transform failure, and no candidates. +- [x] 3.4 Keep existing `generate_grasps(object_name=..., object_id=...)` pointcloud flow unchanged. +- [x] 3.5 Add unit tests for object-id orchestration success, unknown object id, target-bounds forwarding, and compatibility with the existing pointcloud path. + +## 4. Target-conditioned demo update + +- [x] 4.1 Add or configure a deterministic xArm observation pose/keyframe so the wrist camera sees the table object before reconstruction starts. +- [x] 4.2 Update the opt-in VGN MuJoCo demo wiring or helper flow to include object registration and target-prompt/name selection. +- [x] 4.3 Add demo selection logic that waits for runtime registered objects and selects only a registered object matching the configured demo target prompt/name. +- [x] 4.4 Add demo logic that uses the selected runtime-generated object id to call `GraspingModule.generate_grasps_for_object(...)` after reconstruction has a latest TSDF. +- [x] 4.5 Add Target bounds visualization under a stable Rerun path such as `world/grasp_target_bounds` alongside grasp candidates. +- [x] 4.6 Ensure no-target and configured-target-not-visible outcomes are reported clearly and do not fall back to workspace-level VGN. +- [x] 4.7 Ensure existing xArm simulation blueprints remain unchanged unless the opt-in demo is selected. +- [x] 4.8 Add smoke or integration tests for demo construction, observation pose configuration, target resolution path, visualization paths, and no-target reporting. + +## 5. Validation and documentation + +- [x] 5.1 Update documentation with the object-id-targeted demo command, default target selection behavior, observation pose behavior, and expected target-conditioned visual acceptance criteria. +- [x] 5.2 Run targeted pytest for registered object contracts, object registration lookup, target-conditioned VGN module behavior, grasp orchestration, and demo smoke tests. +- [x] 5.3 Run targeted ruff checks for modified Python files. +- [x] 5.4 Run `openspec status --change "target-conditioned-vgn-grasping"` and verify artifacts remain apply-ready. diff --git a/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/.openspec.yaml b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/.openspec.yaml new file mode 100644 index 0000000000..de73b342e8 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-25 diff --git a/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/design.md b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/design.md new file mode 100644 index 0000000000..35026d6705 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/design.md @@ -0,0 +1,97 @@ +## Context + +The core VGN reconstruction change defines the TSDF scene product and VGN grasp candidate output. This companion change validates that pipeline in a full simulated setting. The existing xArm simulation blueprint already uses `MujocoSimModule` with `camera_name="wrist_camera"` and `base_frame_id="link7"`, and Rerun visualization is already available through `RerunBridgeModule` and `PointCloud2.to_rerun()`. + +The first demo should prove geometry and frame correctness, not manipulation execution. A simple scene with one object is enough to show whether wrist depth, TSDF reconstruction, VGN inference, and grasp candidate visualization line up. + +## Goals / Non-Goals + +**Goals:** +- Provide an opt-in MuJoCo demo that runs xArm7, wrist depth camera, scene reconstruction, VGN grasp generation, and Rerun visualization together. +- Use a minimal scene: robot, gripper, table, and one simple stable graspable object. +- Visualize scene pointcloud, optional TSDF surface/voxel points, and simplified gripper candidates in world coordinates. +- Make the best/top grasps visually distinct from lower-scoring candidates. +- Keep the demo deterministic enough for repeated local debugging. + +**Non-Goals:** +- Autonomous physical or simulated pick execution. +- Cluttered object piles or benchmark-quality grasp evaluation. +- Viser as the primary visualization path in v1. +- General multi-object scene generation. + +## Decisions + +### Use Rerun first + +Use Rerun for v1 visualization because the xArm simulation stack already includes `RerunBridgeModule`, and `PointCloud2.to_rerun()` already exists. + +Alternatives considered: +- Viser: preferred long-term by the user, but would require more new infrastructure before verifying VGN geometry. +- Open3D-only debug windows: useful for local debugging, but less integrated with the existing DimOS visualization stack. + +### Render grasp candidates as simplified gripper wireframes + +Add `GraspCandidateArray.to_rerun()` or an equivalent visualization helper that logs simplified grippers with `rr.LineStrips3D`. Each candidate pose transforms local gripper geometry into world; jaw width controls finger separation; score controls color/ranking. + +Use fixed v1 visualization config: + +```python +GraspVisConfig: + max_grasps: int = 50 + top_k_highlight: int = 5 + finger_length_m: float = 0.055 + palm_depth_m: float = 0.035 + finger_thickness_m: float = 0.004 + default_width_m: float = 0.08 + min_score: float = 0.0 + top_color: tuple[int, int, int] = (0, 255, 80) + good_color: tuple[int, int, int] = (255, 220, 0) + low_color: tuple[int, int, int] = (255, 80, 0) +``` + +Alternatives considered: +- Pose arrows only: too thin; does not show jaw width or gripper envelope. +- Mesh grippers: prettier but more implementation work than needed for validation. + +### Keep the scene simple + +Use xArm7 with gripper, table, and one simple object such as a cube or mug-like cylinder with a distinct material. Avoid clutter for v1. + +Alternatives considered: +- Multiple cluttered objects: better stress test, but makes first failure diagnosis harder. +- No object, synthetic TSDF only: easier to run, but does not validate MuJoCo camera and scene visualization together. + +### Wrist camera uses existing MuJoCo camera configuration + +Use the existing wrist camera concept attached to `link7` through `MujocoSimModule` configuration. The demo should ensure depth and camera-info streams are enabled and that TF makes the wrist camera frame resolvable to `world`. + +Alternatives considered: +- Static external camera: simpler view, but does not validate the wrist-camera use case needed for real manipulation. + +### Demo is opt-in and separate from existing xArm perception sim + +Add a new demo blueprint/script rather than changing the default xArm perception simulation behavior. + +Alternatives considered: +- Modify `xarm_perception_sim` directly: simpler to find, but risks changing existing workflows and tests. + +## Risks / Trade-offs + +- MuJoCo asset/keyframe changes can break derived scene paths → keep demo asset additions minimal and verify the selected scene path exists. +- Wrist camera frame or depth intrinsics may not match reconstruction assumptions → add explicit Rerun visualization of pointcloud/TSDF/grasp frames and fail clearly when TF is missing. +- VGN may produce no grasps on a too-simple or badly scaled object → choose object size compatible with parallel-jaw gripper and show reconstruction status/model status. +- Rerun line-strip APIs may differ by version → keep visualization helper small and covered by a smoke test or import test. +- This change depends on the core reconstruction/VGN change → implement after or alongside `vgn-tsdf-scene-reconstruction` and avoid duplicating core types. + +## Migration Plan + +1. Add demo assets and visualization helpers behind opt-in demo names. +2. Wire a new demo blueprint/script without changing existing simulation blueprints. +3. Validate visually in Rerun and with smoke tests. +4. Rollback is removing the opt-in demo and helper wiring. + +## Open Questions + +- Exact object shape for the first committed scene: cube versus cylinder/mug-like object. +- Whether the demo should include scripted arm scan poses in v1 or rely on static wrist camera placement first. +- Whether TSDF surface visualization should be produced by the reconstruction module or a demo-only helper. diff --git a/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/proposal.md b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/proposal.md new file mode 100644 index 0000000000..65b1f6c048 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/proposal.md @@ -0,0 +1,26 @@ +## Why + +VGN grasp integration is hard to validate from unit tests alone because correctness depends on camera geometry, reconstruction frames, object placement, and visualization. A MuJoCo end-to-end demo gives a repeatable scene where generated grasps can be inspected against the robot, table, object, pointcloud, and TSDF-derived scene output. + +## What Changes + +- Add a simulation demo around xArm7 with a wrist depth camera, table, and one simple graspable object. +- Compose MuJoCo depth output, scene reconstruction, VGN grasp generation, and Rerun visualization into an opt-in demo blueprint/script. +- Visualize object/table scene pointcloud, optional TSDF surface/voxel points, and simplified gripper grasp candidates in a fixed Rerun layout. +- Add reusable Rerun visualization for `GraspCandidateArray` using simplified gripper wireframes. +- Keep v1 focused on visual/algorithm verification, not autonomous pick execution. + +## Capabilities + +### New Capabilities +- `vgn-mujoco-grasp-demo`: End-to-end simulated VGN grasp proposal demo with wrist depth camera and Rerun grasp visualization. + +### Modified Capabilities +- None. + +## Impact + +- Affected code areas: xArm simulation blueprints/demos, MuJoCo scene assets, Rerun visualization helpers, and grasp candidate visualization. +- Depends on the core `vgn-tsdf-scene-reconstruction` change for `TSDFGrid`, scene reconstruction, and VGN grasp candidate output. +- Uses existing MuJoCo camera stream support and existing Rerun bridge infrastructure where possible. +- Does not change real robot behavior or existing pick-and-place heuristics. diff --git a/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md new file mode 100644 index 0000000000..bb7e117efc --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/specs/vgn-mujoco-grasp-demo/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Simulated VGN grasp demo +The system SHALL provide an opt-in MuJoCo demo that composes xArm simulation, wrist depth camera output, scene reconstruction, VGN grasp generation, and visualization. + +#### Scenario: Demo composition starts +- **WHEN** the demo blueprint or script is launched with required optional dependencies available +- **THEN** it starts the simulated xArm scene, depth camera streams, scene reconstruction, VGN grasp generation, and Rerun visualization path + +#### Scenario: Existing simulation remains unchanged +- **WHEN** existing xArm simulation blueprints are launched +- **THEN** their behavior is not changed by the VGN demo unless the new demo is selected explicitly + +### Requirement: Minimal graspable scene +The demo SHALL include a repeatable simulated scene with xArm7, gripper, table, and one simple stable graspable object. + +#### Scenario: Scene contains object and support +- **WHEN** the demo scene loads +- **THEN** the robot, gripper, table, and graspable object are present in consistent world-frame positions + +#### Scenario: Object suitable for first validation +- **WHEN** VGN reconstruction and inference run against the demo object +- **THEN** the object scale and placement are compatible with parallel-jaw grasp proposal visualization + +### Requirement: Wrist depth camera validation +The demo SHALL use a wrist-mounted simulated depth camera attached through the existing xArm/MuJoCo camera mechanism. + +#### Scenario: Depth streams available +- **WHEN** the demo starts +- **THEN** depth image and depth camera info streams are available for scene reconstruction + +#### Scenario: Camera transform resolvable +- **WHEN** the reconstruction module receives a depth image from the wrist camera +- **THEN** the camera frame can be resolved to `world` through TF or the frame failure is reported clearly + +### Requirement: Rerun scene visualization +The demo SHALL visualize enough scene context in Rerun to judge whether generated grasps align with the object. + +#### Scenario: Scene products visible +- **WHEN** reconstruction products are available +- **THEN** Rerun shows the object/table scene pointcloud and a TSDF-derived surface or voxel visualization in world coordinates + +#### Scenario: Fixed Rerun entity layout +- **WHEN** the demo logs visualization data +- **THEN** it uses stable entity paths under `world/`, including `world/scene_pointcloud`, `world/tsdf_surface`, and `world/grasp_candidates` + +### Requirement: Simplified gripper candidate visualization +The demo SHALL visualize grasp candidates as simplified gripper wireframes rather than pose arrows only. + +#### Scenario: Candidate wireframes rendered +- **WHEN** a `GraspCandidateArray` is available +- **THEN** Rerun displays up to the configured maximum number of candidates as line-strip gripper wireframes transformed by each candidate pose + +#### Scenario: Width and score visible +- **WHEN** candidates include jaw width and score +- **THEN** jaw width controls finger separation and score controls ordering or color so top candidates are visually distinct + +#### Scenario: No candidates +- **WHEN** VGN returns no candidates +- **THEN** the demo reports the no-candidate outcome clearly instead of showing stale grippers diff --git a/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/tasks.md b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/tasks.md new file mode 100644 index 0000000000..d2392febf2 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-mujoco-grasp-demo/tasks.md @@ -0,0 +1,29 @@ +## 1. Demo scene and simulation wiring + +- [x] 1.1 Identify the existing xArm MuJoCo scene asset and wrist camera configuration used by `xarm_perception_sim`. +- [x] 1.2 Add or select a minimal demo scene with xArm7, gripper, table, and one stable graspable object. +- [x] 1.3 Ensure the simulated wrist camera publishes depth image and depth camera info streams for reconstruction. +- [x] 1.4 Verify the wrist camera frame can be resolved to `world` through TF during the demo. + +## 2. Demo blueprint/script + +- [x] 2.1 Create an opt-in VGN grasp demo blueprint or `demo_*` script that does not modify existing xArm simulation defaults. +- [x] 2.2 Compose MuJoCo simulation, scene reconstruction, VGN grasp generation, and Rerun visualization modules. +- [x] 2.3 Configure reconstruction workspace and output rate for the demo scene. +- [x] 2.4 Add a simple demo flow that resets reconstruction, allows/causes depth integration, triggers grasp generation, and keeps visualization live. + +## 3. Grasp visualization + +- [x] 3.1 Implement `GraspVisConfig` with the fixed v1 parameters from the design. +- [x] 3.2 Add `GraspCandidateArray` Rerun visualization using simplified gripper `LineStrips3D` wireframes. +- [x] 3.3 Map candidate jaw width to finger separation and score/rank to color or highlight style. +- [x] 3.4 Clear or update visualization explicitly when no candidates are available to avoid stale grippers. +- [x] 3.5 Add optional TSDF surface or voxel-point visualization under a stable Rerun entity path. + +## 4. Validation + +- [x] 4.1 Add a lightweight smoke test for demo imports and blueprint/script construction. +- [x] 4.2 Add a visualization helper test for gripper line geometry shape/count using synthetic grasp candidates. +- [x] 4.3 Run the demo locally and confirm Rerun shows scene pointcloud, TSDF-derived surface/voxels, and grasp wireframes in world alignment. +- [x] 4.4 Document the demo command and expected visual acceptance criteria. +- [x] 4.5 Run `openspec status --change "vgn-mujoco-grasp-demo"` and verify artifacts remain apply-ready. diff --git a/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/.openspec.yaml b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/.openspec.yaml new file mode 100644 index 0000000000..de73b342e8 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-25 diff --git a/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/design.md b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/design.md new file mode 100644 index 0000000000..335b2d0658 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/design.md @@ -0,0 +1,92 @@ +## Context + +DimOS already has depth-capable camera streams (`depth_image`, `depth_camera_info`, TF, and optional `pointcloud`) and a grasp-generation orchestration seam (`GraspingModule` + `GraspGenSpec`). What is missing is a native learned grasp backend and the scene reconstruction representation required by VGN. + +Official VGN reconstructs a TSDF from depth images, camera intrinsics, and camera poses, then runs inference on a grid shaped like `(1, 40, 40, 40)`. It does not build its canonical input from a pointcloud alone. DimOS should therefore model TSDF as a reusable scene product produced beside pointclouds, not as a private VGN adapter detail. + +## Goals / Non-Goals + +**Goals:** +- Introduce a reusable scene reconstruction module that consumes depth observations and publishes pointcloud, TSDF, and status outputs. +- Introduce a `TSDFGrid` message aligned with VGN/Open3D grid semantics. +- Introduce TSDF-native grasp generation with VGN as the first backend. +- Preserve existing pointcloud-oriented `GraspGenSpec` and existing heuristic pick behavior. +- Publish grasp outputs as scored gripper candidates in `world` frame, with PoseArray compatibility. +- Keep high-rate/large sensor and reconstruction payloads on streams; use RPC for control and lifecycle. + +**Non-Goals:** +- Executing robot picks or closing the gripper using VGN output. +- Supporting cluttered bin-picking quality in the first pass. +- Making Viser the primary visualization path in this change. +- Replacing `ObjectSceneRegistrationModule` or semantic object registration. +- Creating a pointcloud-only approximation as the primary VGN path. + +## Decisions + +### Scene reconstruction is generic, not VGN-private + +Create a `SceneReconstructionModule` that consumes depth streams and TF and publishes reconstructed scene products. VGN consumes the TSDF product. + +Alternatives considered: +- Put TSDF integration inside `VGNGraspGenModule`: simpler initially, but hides a reusable scene representation and couples reconstruction lifecycle to one model. +- Build TSDF from `PointCloud2`: easier to wire to existing pointcloud APIs, but loses camera-ray/free-space information needed for faithful TSDF integration. + +### TSDFGrid uses min-corner grid semantics + +`TSDFGrid` carries `distances` shaped `(1, X, Y, Z)`, `voxel_size`, `truncation_distance`, `origin`, `size`, `resolution`, optional `weights`, `frame_id`, and `ts`. Voxel `[0,0,0]` is located at the grid origin/min corner. Workspace RPCs can accept a user-friendly center and convert internally. + +Alternatives considered: +- Center-origin grids: friendlier for humans but adds conversion friction and ambiguity versus VGN/Open3D output. +- VGN-specific wrapper object: less reusable and harder to stream across DimOS modules. + +### Reconstruction continuously ingests but publishes at a fixed output rate + +The reconstruction module may ingest at camera rate or a configured throttle, but publishes `pointcloud`, `tsdf`, and `status` at `reconstruction_fps` such as 2 Hz. RPCs control lifecycle: reset, pause/resume integration, set workspace, snapshot, and status. + +Alternatives considered: +- One-shot RPC carrying depth/camera/TF payloads: easier to reason about for one request, but sends large pickled payloads through RPC and fights DimOS stream architecture. +- Fully manual start/stop-only reconstruction: faithful to scan workflows, but less useful as a general scene product producer. + +### TSDF grasp generation gets a new spec + +Add `TSDFGraspGenSpec.generate_grasps_from_tsdf(tsdf: TSDFGrid) -> GraspCandidateArray | None`. Keep the existing pointcloud `GraspGenSpec` unchanged for pointcloud-based backends. + +Alternatives considered: +- Change `GraspGenSpec` to accept TSDF: would break the existing seam and mix two different input contracts. +- Add optional pointcloud to TSDF generation spec: VGN does not require it for inference, so it should remain a sibling visualization/collision/debug output. + +### Grasp candidates are richer than PoseArray + +Introduce `GraspCandidate` and `GraspCandidateArray` with pose, jaw width, score, and optional id. Publish this as primary output and provide `to_pose_array()` compatibility. + +Alternatives considered: +- Only publish `PoseArray`: insufficient for VGN score, gripper width, visualization, ranking, and later execution. + +### VGN outputs world-frame candidates in v1 + +The VGN module transforms voxel/grid-frame grasps through `TSDFGrid.origin` and TF into `world`, then publishes `GraspCandidateArray.header.frame_id = "world"`. If the transform is unavailable, it returns no result and logs/raises a clear error. + +Alternatives considered: +- Configurable target frame: more flexible, but unnecessary for the first integration and creates more validation combinations. + +## Risks / Trade-offs + +- VGN import triggers ROS visualization side effects in non-ROS environments → lazy-import VGN inside the backend and isolate or shim visualization imports rather than importing `vgn.detection` at module import time. +- TSDF coordinate conventions can silently produce shifted grasps → require unit tests for voxel-to-world conversion and frame id/origin semantics. +- Reconstruction payloads are large → stream them and avoid RPC transport for TSDF/depth arrays. +- VGN model artifacts may not be present → fail with a clear backend status and installation/model-path message. +- Depth/camera/TF synchronization can be brittle → align by timestamps where available and expose reconstruction status with accepted/dropped frame counts. +- `CONTEXT.md` currently has merge conflict markers → do not update glossary/docs there until conflicts are resolved. + +## Migration Plan + +1. Add new message/spec/module types without changing existing grasp or pick paths. +2. Wire VGN into new opt-in blueprints/demos only. +3. Keep `GraspingModule` pointcloud flow and `PickAndPlaceModule` heuristic flow untouched. +4. Rollback is removing the opt-in modules/blueprints while leaving existing behavior unchanged. + +## Open Questions + +- Exact location/name for the reconstruction package: `dimos/perception/reconstruction` versus `dimos/navigation` or another namespace. +- Whether `TSDFGrid.weights` should be required immediately or optional until a downstream consumer needs confidence/observation counts. +- Exact model artifact discovery convention for VGN weights. diff --git a/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/proposal.md b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/proposal.md new file mode 100644 index 0000000000..81d993d554 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/proposal.md @@ -0,0 +1,29 @@ +## Why + +DimOS has an intended grasp-generation seam, but no working native learned grasp backend. VGN is now installable behind the `grasp` extra, and integrating it cleanly requires a depth-based scene reconstruction path because VGN consumes TSDF grids reconstructed from depth observations, not raw pointcloud-only inputs. + +## What Changes + +- Add a generic scene reconstruction capability that consumes depth images, depth camera calibration, and TF to maintain a reconstructed scene. +- Add a `TSDFGrid` message type aligned with Open3D/VGN grid semantics. +- Publish reconstructed scene products at a fixed reconstruction output rate, including `PointCloud2`, `TSDFGrid`, and reconstruction status. +- Add TSDF-native grasp generation contracts and a VGN-backed module that consumes latest TSDF data. +- Add grasp candidate message types carrying pose, jaw width, and score, with PoseArray compatibility for existing consumers. +- Output VGN grasp candidates in `world` frame for simple downstream use. +- Keep high-rate sensor data on streams; use RPC for reconstruction lifecycle/control only. + +## Capabilities + +### New Capabilities +- `scene-reconstruction`: Generic depth-based scene reconstruction that produces pointcloud and TSDF scene products from depth observations. +- `tsdf-grasp-generation`: TSDF-native grasp generation that converts reconstructed TSDF grids into scored world-frame grasp candidates. + +### Modified Capabilities +- None. + +## Impact + +- Affected code areas: `dimos/msgs`, `dimos/perception` or reconstruction modules, `dimos/manipulation/grasping`, and xArm/manipulation blueprints that opt into the new modules. +- New runtime path depends on the existing `grasp` optional extra for VGN and existing depth camera streams. +- Existing `GraspGenSpec` and pointcloud-based grasp paths remain available; this change adds a TSDF-native path rather than replacing them. +- Sensor payloads remain stream-based to avoid large pickled RPC requests. diff --git a/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md new file mode 100644 index 0000000000..80e32ce909 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/specs/scene-reconstruction/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Depth-based scene reconstruction +The system SHALL provide a scene reconstruction capability that consumes depth images, depth camera calibration, and camera pose information to maintain a reconstructed scene. + +#### Scenario: Integrate depth observation +- **WHEN** a depth image, matching depth camera info, and a TF transform for the depth image frame at the image timestamp are available +- **THEN** the reconstruction capability integrates the observation into the current scene reconstruction + +#### Scenario: Missing transform +- **WHEN** a depth image is available but the camera pose cannot be resolved within the configured tolerance +- **THEN** the reconstruction capability does not integrate that image and records the dropped frame in reconstruction status + +### Requirement: Publish scene products +The system SHALL publish reconstructed scene products as streams, including a pointcloud, a TSDF grid, and reconstruction status. + +#### Scenario: Fixed output rate +- **WHEN** reconstruction is active and scene data is available +- **THEN** the system publishes scene products at the configured reconstruction output rate rather than by RPC request + +#### Scenario: Pointcloud and TSDF share source observations +- **WHEN** the system publishes both pointcloud and TSDF products +- **THEN** both products are derived from the same integrated depth observations rather than converting TSDF from an unrelated pointcloud-only source + +### Requirement: Reconstruction lifecycle control +The system SHALL expose RPC controls for scene reconstruction lifecycle and workspace configuration while keeping large scene payloads on streams. + +#### Scenario: Reset scene +- **WHEN** a caller invokes reset scene control +- **THEN** the current integrated scene is cleared and subsequent scene products reflect only observations integrated after the reset + +#### Scenario: Pause and resume integration +- **WHEN** a caller pauses integration +- **THEN** incoming depth observations are not integrated until integration is resumed + +#### Scenario: Set workspace +- **WHEN** a caller sets a workspace frame, center, and size +- **THEN** the reconstruction capability reconstructs subsequent TSDF data in that workspace and converts the center specification into the internal grid-origin representation + +### Requirement: TSDF grid message +The system SHALL represent TSDF output with a streamable `TSDFGrid` data type using VGN/Open3D-aligned grid semantics. + +#### Scenario: VGN-compatible grid shape +- **WHEN** a TSDF grid is published for VGN consumption +- **THEN** its distances array is a float32 array shaped `(1, X, Y, Z)` and includes resolution metadata matching `X`, `Y`, and `Z` + +#### Scenario: Grid origin semantics +- **WHEN** a consumer maps voxel index `[i, j, k]` to metric space +- **THEN** the voxel position is computed from the grid min-corner origin plus `[i, j, k] * voxel_size` in the TSDF grid frame + +#### Scenario: Optional weights +- **WHEN** integration weights or observation counts are available +- **THEN** the TSDF grid may include weights aligned with the distances grid; otherwise weights are absent without invalidating the TSDF grid diff --git a/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md new file mode 100644 index 0000000000..b4d8518348 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/specs/tsdf-grasp-generation/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: TSDF-native grasp generation +The system SHALL provide a TSDF-native grasp generation capability that accepts a `TSDFGrid` and returns scored grasp candidates. + +#### Scenario: Generate grasps from TSDF +- **WHEN** a valid TSDF grid is provided to the TSDF grasp generator +- **THEN** the generator returns a `GraspCandidateArray` or a clear no-result outcome + +#### Scenario: No pointcloud required +- **WHEN** VGN-backed grasp generation runs from a TSDF grid +- **THEN** it does not require a pointcloud input for inference + +### Requirement: VGN grasp backend +The system SHALL include a VGN-backed TSDF grasp generation module that consumes latest TSDF stream data and can also generate grasps from an explicit TSDF grid through a typed spec. + +#### Scenario: Latest TSDF generation +- **WHEN** the VGN module has received a latest TSDF grid and a caller requests grasp generation from latest TSDF +- **THEN** the module runs VGN inference on that TSDF grid and publishes the resulting grasp candidates + +#### Scenario: Missing model dependency +- **WHEN** VGN or its model weights are unavailable +- **THEN** the module fails with a clear status or error explaining the missing optional dependency or model path instead of failing at process import time + +### Requirement: Grasp candidate output +The system SHALL represent learned grasp output as grasp candidates containing pose, jaw width, score, and optional id. + +#### Scenario: Candidate metadata +- **WHEN** VGN produces a grasp proposal +- **THEN** the output candidate includes the proposed gripper pose, jaw width in meters, quality score, and a stable id when available + +#### Scenario: PoseArray compatibility +- **WHEN** an existing consumer requires a `PoseArray` +- **THEN** the grasp candidate array can be converted to a `PoseArray` without losing pose ordering + +### Requirement: World-frame VGN output +The system SHALL publish VGN grasp candidates in the `world` frame for the first integration. + +#### Scenario: Transform successful +- **WHEN** VGN predicts a grasp in TSDF grid coordinates and the transform to `world` is available +- **THEN** the published candidate pose is transformed to `world` and the output header frame id is `world` + +#### Scenario: Transform unavailable +- **WHEN** the transform from TSDF grid frame to `world` cannot be resolved +- **THEN** the generator does not publish misleading candidates and reports a clear transform failure + +### Requirement: Stream-first data flow +The system SHALL keep TSDF, depth, and other large scene payloads on streams and use RPC only for control or request/response operations that do not carry high-rate sensor payloads. + +#### Scenario: Avoid large RPC payloads +- **WHEN** reconstructing a scene or generating grasps from live camera data +- **THEN** depth images and TSDF grids flow through streams rather than being bundled into a single pickled RPC request diff --git a/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/tasks.md b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/tasks.md new file mode 100644 index 0000000000..1958ae3d8a --- /dev/null +++ b/openspec/changes/archive/2026-06-26-vgn-tsdf-scene-reconstruction/tasks.md @@ -0,0 +1,34 @@ +## 1. Message and spec contracts + +- [x] 1.1 Add `TSDFGrid` message type with frame id, timestamp, distances, voxel size, truncation distance, origin transform, size, resolution, and optional weights. +- [x] 1.2 Add `ReconstructionStatus` message type with active/paused state, workspace metadata, accepted frame count, dropped frame count, latest integration timestamp, and latest error/status text. +- [x] 1.3 Add `GraspCandidate` and `GraspCandidateArray` message types with pose, jaw width, score, optional id, header, and `to_pose_array()` compatibility. +- [x] 1.4 Add or update serialization/encode/decode helpers and type annotations for new messages. +- [x] 1.5 Add unit tests for `TSDFGrid` shape metadata, voxel-to-frame coordinate semantics, and `GraspCandidateArray.to_pose_array()` ordering. + +## 2. Scene reconstruction module + +- [x] 2.1 Create a generic `SceneReconstructionModule` consuming `depth_image` and `depth_camera_info` streams plus TF lookups. +- [x] 2.2 Implement workspace configuration using user-facing frame, center, and size while storing TSDF grid origin as the min-corner frame transform. +- [x] 2.3 Implement continuous depth integration with configurable ingest throttle and fixed `reconstruction_fps` publication. +- [x] 2.4 Publish `pointcloud`, `tsdf`, and `status` streams derived from the same accepted depth observations. +- [x] 2.5 Add RPC controls: `reset_scene`, `pause_integration`, `resume_integration`, `set_workspace`, `snapshot_scene`, and `get_reconstruction_status`. +- [x] 2.6 Track and publish dropped-frame status when camera info or TF is missing or stale. +- [x] 2.7 Add unit tests for lifecycle controls, publication throttling, missing-transform handling, and workspace origin conversion. + +## 3. TSDF grasp generation module + +- [x] 3.1 Add `TSDFGraspGenSpec.generate_grasps_from_tsdf(tsdf: TSDFGrid) -> GraspCandidateArray | None`. +- [x] 3.2 Implement `VGNGraspGenModule` with lazy VGN imports and clear errors for missing optional dependency or model weights. +- [x] 3.3 Subscribe to latest `TSDFGrid` stream and expose RPC generation from latest TSDF. +- [x] 3.4 Convert VGN voxel-coordinate predictions into `TSDFGrid` frame poses using voxel size and grid origin semantics. +- [x] 3.5 Transform grasp candidates into `world` frame and report a clear failure when the transform is unavailable. +- [x] 3.6 Publish primary `GraspCandidateArray` output and optional PoseArray compatibility output. +- [x] 3.7 Add unit tests for lazy import failure, VGN output conversion, world-frame transform behavior, and no-pointcloud-required inference path. + +## 4. Wiring and validation + +- [x] 4.1 Add opt-in blueprint wiring for depth camera → scene reconstruction → VGN grasp generation without changing existing pick-and-place behavior. +- [x] 4.2 Add smoke tests or lightweight integration tests using synthetic TSDF data and stubbed VGN outputs. +- [x] 4.3 Run targeted pytest for new messages, reconstruction module, and grasp generation module. +- [x] 4.4 Run `openspec status --change "vgn-tsdf-scene-reconstruction"` and verify artifacts remain apply-ready. From 16dc77b77a8379787ae3c8a1cff0d94d9fac6811 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 12:21:37 -0700 Subject: [PATCH 082/110] chore: failed attempt on vgn tuning... --- .../grasping/vgn_grasp_gen_module.py | 45 +++++++- dimos/msgs/reconstruction_msgs/TSDFGrid.py | 6 +- .../msgs/reconstruction_msgs/test_TSDFGrid.py | 4 +- .../reconstruction/test_tsdf_debug_export.py | 5 + .../reconstruction/tsdf_debug_export.py | 108 +++++++++++++++++- .../xarm/blueprints/simulation.py | 6 +- .../blueprints/test_vgn_mujoco_grasp_demo.py | 7 +- docs/usage/vgn_mujoco_grasp_demo.md | 2 +- 8 files changed, 170 insertions(+), 13 deletions(-) diff --git a/dimos/manipulation/grasping/vgn_grasp_gen_module.py b/dimos/manipulation/grasping/vgn_grasp_gen_module.py index 8eab08b321..299b964b99 100644 --- a/dimos/manipulation/grasping/vgn_grasp_gen_module.py +++ b/dimos/manipulation/grasping/vgn_grasp_gen_module.py @@ -47,6 +47,10 @@ class VGNGraspGenModuleConfig(ModuleConfig): model_path_env: str = "DIMOS_VGN_MODEL_PATH" output_frame: str = "world" default_jaw_width: float = 0.08 + quality_threshold: float = 0.90 + width_filter_min_voxels: float = 1.33 + width_filter_max_voxels: float = 9.33 + filter_candidates_to_target_bounds: bool = True min_score: float = 0.0 auto_generate_on_tsdf: bool = False auto_generate_min_interval: float = 1.0 @@ -177,7 +181,11 @@ def generate_grasps_for_target_bounds( result = self._generate_grasps_from_tsdf(masked_tsdf) if result is None: return None - filtered = self._filter_candidates_to_bounds(result, bounds, cushion_m) + filtered = ( + self._filter_candidates_to_bounds(result, bounds, cushion_m) + if self.config.filter_candidates_to_target_bounds + else result + ) self.grasp_candidates.publish(filtered) self.grasp_poses.publish(filtered.to_pose_array()) return filtered @@ -249,7 +257,12 @@ def _get_detector(self) -> _VGNDetector: "VGN ROS visualization dependency %s is unavailable; using headless VGN detector", exc.name, ) - self._detector = _HeadlessVGNDetector(model_path) + self._detector = _HeadlessVGNDetector( + model_path, + quality_threshold=self.config.quality_threshold, + width_filter_min_voxels=self.config.width_filter_min_voxels, + width_filter_max_voxels=self.config.width_filter_max_voxels, + ) return self._detector def _resolve_model_path(self) -> str: @@ -393,13 +406,23 @@ def _filter_candidates_to_bounds( class _HeadlessVGNDetector: """Run pinned VGN inference without importing ROS-backed `vgn.vis`.""" - def __init__(self, model_path: str) -> None: + def __init__( + self, + model_path: str, + *, + quality_threshold: float = 0.90, + width_filter_min_voxels: float = 1.33, + width_filter_max_voxels: float = 9.33, + ) -> None: import torch # type: ignore[import-not-found] from vgn.networks import get_network # type: ignore[import-not-found] self._torch = torch self._device = _select_torch_device(torch) self._net = _load_vgn_network(torch, get_network, Path(model_path), self._device) + self._quality_threshold = quality_threshold + self._width_filter_min_voxels = width_filter_min_voxels + self._width_filter_max_voxels = width_filter_max_voxels logger.info("Loaded headless VGN detector", device=str(self._device)) def __call__(self, state: _VGNState) -> tuple[Sequence[object], Sequence[float], float]: @@ -423,9 +446,21 @@ def __call__(self, state: _VGNState) -> tuple[Sequence[object], Sequence[float], mask=np.logical_not(inside_voxels), ) quality_volume[np.logical_not(valid_voxels)] = 0.0 - quality_volume[np.logical_or(width_volume < 1.33, width_volume > 9.33)] = 0.0 + quality_volume[ + np.logical_or( + width_volume < self._width_filter_min_voxels, + width_volume > self._width_filter_max_voxels, + ) + ] = 0.0 - quality_volume[quality_volume < 0.90] = 0.0 + logger.info( + "VGN quality stats before threshold", + max_quality=float(quality_volume.max()) if quality_volume.size else 0.0, + threshold=self._quality_threshold, + width_min=float(width_volume.min()) if width_volume.size else 0.0, + width_max=float(width_volume.max()) if width_volume.size else 0.0, + ) + quality_volume[quality_volume < self._quality_threshold] = 0.0 max_volume = ndimage.maximum_filter(quality_volume, size=4) quality_volume = np.where(quality_volume == max_volume, quality_volume, 0.0) diff --git a/dimos/msgs/reconstruction_msgs/TSDFGrid.py b/dimos/msgs/reconstruction_msgs/TSDFGrid.py index 960d299117..b08986d19a 100644 --- a/dimos/msgs/reconstruction_msgs/TSDFGrid.py +++ b/dimos/msgs/reconstruction_msgs/TSDFGrid.py @@ -24,6 +24,8 @@ from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.types.timestamped import Timestamped +DEFAULT_RERUN_SURFACE_LEVEL = 0.1 + class TSDFGrid(Timestamped): """Dense TSDF grid with min-corner origin semantics. @@ -130,7 +132,7 @@ def to_rerun(self, max_points: int = 25_000) -> object: import rerun as rr # type: ignore[import-not-found] field = self.distances[0] - surface_mask = np.abs(field) <= self.voxel_size + surface_mask = np.abs(field) <= DEFAULT_RERUN_SURFACE_LEVEL if self.weights is not None: weights = self.weights[0] if self.weights.ndim == 4 else self.weights surface_mask = np.logical_and(surface_mask, weights > 0.0) @@ -143,7 +145,7 @@ def to_rerun(self, max_points: int = 25_000) -> object: origin = np.array([self.origin.x, self.origin.y, self.origin.z], dtype=np.float32) points = origin + surface.astype(np.float32) * np.float32(self.voxel_size) - values = np.clip(np.abs(field[tuple(surface.T)]) / self.truncation_distance, 0.0, 1.0) + values = np.clip(np.abs(field[tuple(surface.T)]), 0.0, 1.0) colors = np.stack( [ (255.0 * (1.0 - values)).astype(np.uint8), diff --git a/dimos/msgs/reconstruction_msgs/test_TSDFGrid.py b/dimos/msgs/reconstruction_msgs/test_TSDFGrid.py index f2322fc25c..d09e55ec32 100644 --- a/dimos/msgs/reconstruction_msgs/test_TSDFGrid.py +++ b/dimos/msgs/reconstruction_msgs/test_TSDFGrid.py @@ -90,8 +90,10 @@ def test_tsdf_grid_rejects_invalid_shape() -> None: def test_tsdf_grid_to_rerun_filters_unobserved_surface_voxels() -> None: distances = np.ones((1, 2, 2, 2), dtype=np.float32) distances[0, 0, 0, 0] = 0.0 + distances[0, 0, 1, 1] = 0.08 distances[0, 1, 1, 1] = 0.0 weights = np.zeros((2, 2, 2), dtype=np.float32) + weights[0, 1, 1] = 1.0 weights[1, 1, 1] = 1.0 grid = TSDFGrid( distances=distances, @@ -105,7 +107,7 @@ def test_tsdf_grid_to_rerun_filters_unobserved_surface_voxels() -> None: assert isinstance(archetype, rr.Points3D) points = cast("rr.Points3D", archetype) - assert len(points.positions.as_arrow_array().to_pylist()) == 1 + assert len(points.positions.as_arrow_array().to_pylist()) == 2 def test_reconstruction_status_lcm_round_trip() -> None: diff --git a/dimos/perception/reconstruction/test_tsdf_debug_export.py b/dimos/perception/reconstruction/test_tsdf_debug_export.py index b6eaca2f45..47424ae4ac 100644 --- a/dimos/perception/reconstruction/test_tsdf_debug_export.py +++ b/dimos/perception/reconstruction/test_tsdf_debug_export.py @@ -42,6 +42,8 @@ def test_export_tsdf_debug_files_writes_raw_grid_and_open3d_pointclouds(tmp_path "target_masked_tsdf.npz", "target_masked_tsdf_near_surface.ply", "target_masked_tsdf_observed.ply", + "target_masked_tsdf_slices.png", + "target_masked_tsdf_summary.json", } with np.load(tmp_path / "target_masked_tsdf.npz", allow_pickle=False) as data: np.testing.assert_array_equal(data["distances"], distances) @@ -53,3 +55,6 @@ def test_export_tsdf_debug_files_writes_raw_grid_and_open3d_pointclouds(tmp_path observed = o3d.io.read_point_cloud(str(tmp_path / "target_masked_tsdf_observed.ply")) assert len(near_surface.points) == 1 assert len(observed.points) == 2 + assert (tmp_path / "target_masked_tsdf_slices.png").stat().st_size > 0 + assert '"observed_voxels": 2' in (tmp_path / "target_masked_tsdf_summary.json").read_text() + assert '"surface_voxels": 1' in (tmp_path / "target_masked_tsdf_summary.json").read_text() diff --git a/dimos/perception/reconstruction/tsdf_debug_export.py b/dimos/perception/reconstruction/tsdf_debug_export.py index 01123ba1af..453c11aa92 100644 --- a/dimos/perception/reconstruction/tsdf_debug_export.py +++ b/dimos/perception/reconstruction/tsdf_debug_export.py @@ -14,14 +14,32 @@ from __future__ import annotations +import json from pathlib import Path import re +from typing import Protocol +import matplotlib # type: ignore[import-not-found] + +matplotlib.use("Agg") +from matplotlib import pyplot as plt # type: ignore[import-not-found] import numpy as np import open3d as o3d # type: ignore[import-untyped] from dimos.msgs.reconstruction_msgs.TSDFGrid import TSDFGrid +DEFAULT_SURFACE_LEVEL = 0.1 + + +class _AxisLike(Protocol): + def imshow(self, values: object, **kwargs: object) -> object: ... + + def set_title(self, title: str) -> object: ... + + def set_xlabel(self, label: str) -> object: ... + + def set_ylabel(self, label: str) -> object: ... + def export_tsdf_debug_files(tsdf: TSDFGrid, output_dir: str | Path, prefix: str) -> list[Path]: """Write TSDF debug artifacts for offline inspection. @@ -36,6 +54,8 @@ def export_tsdf_debug_files(tsdf: TSDFGrid, output_dir: str | Path, prefix: str) npz_path = root / f"{safe_prefix}.npz" near_surface_path = root / f"{safe_prefix}_near_surface.ply" observed_path = root / f"{safe_prefix}_observed.ply" + slices_path = root / f"{safe_prefix}_slices.png" + summary_path = root / f"{safe_prefix}_summary.json" np.savez_compressed( npz_path, @@ -50,12 +70,14 @@ def export_tsdf_debug_files(tsdf: TSDFGrid, output_dir: str | Path, prefix: str) _write_point_cloud(near_surface_path, _near_surface_points(tsdf)) _write_point_cloud(observed_path, _observed_points(tsdf)) - return [npz_path, near_surface_path, observed_path] + _write_slice_image(slices_path, tsdf) + _write_summary(summary_path, tsdf) + return [npz_path, near_surface_path, observed_path, slices_path, summary_path] def _near_surface_points(tsdf: TSDFGrid) -> np.ndarray: field = tsdf.distances[0] - mask = np.abs(field) <= tsdf.voxel_size + mask = np.abs(field) <= DEFAULT_SURFACE_LEVEL if tsdf.weights is not None: weights = tsdf.weights[0] if tsdf.weights.ndim == 4 else tsdf.weights mask = np.logical_and(mask, weights > 0.0) @@ -84,5 +106,87 @@ def _write_point_cloud(path: Path, points: np.ndarray) -> None: o3d.io.write_point_cloud(str(path), pcd, write_ascii=False) +def _write_slice_image(path: Path, tsdf: TSDFGrid) -> None: + field = tsdf.distances[0] + weights = _weights(tsdf) + observed = weights > 0.0 if weights is not None else np.ones_like(field, dtype=bool) + surface = np.logical_and(np.abs(field) <= DEFAULT_SURFACE_LEVEL, observed) + + x_mid = field.shape[0] // 2 + y_mid = field.shape[1] // 2 + z_mid = field.shape[2] // 2 + + fig, axes = plt.subplots(2, 3, figsize=(12, 8), constrained_layout=True) + fig.suptitle( + f"TSDF {tsdf.frame_id} origin=({tsdf.origin.x:.3f}, {tsdf.origin.y:.3f}, {tsdf.origin.z:.3f}) " + f"voxel={tsdf.voxel_size:.4f} observed={int(observed.sum())} surface={int(surface.sum())}" + ) + + _plot_slice(axes[0, 0], field[x_mid, :, :], f"distance x={x_mid}") + _plot_slice(axes[0, 1], field[:, y_mid, :], f"distance y={y_mid}") + _plot_slice(axes[0, 2], field[:, :, z_mid], f"distance z={z_mid}") + _plot_mask(axes[1, 0], observed.max(axis=2), "observed XY projection") + _plot_mask(axes[1, 1], surface.max(axis=2), "surface XY projection") + if weights is None: + _plot_mask(axes[1, 2], observed[:, :, z_mid], f"observed z={z_mid}") + else: + _plot_weight(axes[1, 2], weights[:, :, z_mid], f"weight z={z_mid}") + + fig.savefig(path, dpi=160) + plt.close(fig) + + +def _write_summary(path: Path, tsdf: TSDFGrid) -> None: + field = tsdf.distances[0] + weights = _weights(tsdf) + observed = weights > 0.0 if weights is not None else np.ones_like(field, dtype=bool) + surface = np.logical_and(np.abs(field) <= DEFAULT_SURFACE_LEVEL, observed) + observed_values = field[observed] + summary = { + "frame_id": tsdf.frame_id, + "ts": tsdf.ts, + "origin": [tsdf.origin.x, tsdf.origin.y, tsdf.origin.z], + "voxel_size": tsdf.voxel_size, + "truncation_distance": tsdf.truncation_distance, + "resolution": list(tsdf.resolution), + "surface_level": DEFAULT_SURFACE_LEVEL, + "observed_voxels": int(observed.sum()), + "surface_voxels": int(surface.sum()), + "distance_min": float(observed_values.min()) if observed_values.size else None, + "distance_max": float(observed_values.max()) if observed_values.size else None, + "distance_mean": float(observed_values.mean()) if observed_values.size else None, + } + path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + + +def _weights(tsdf: TSDFGrid) -> np.ndarray | None: + if tsdf.weights is None: + return None + return tsdf.weights[0] if tsdf.weights.ndim == 4 else tsdf.weights + + +def _plot_slice(axis: _AxisLike, values: np.ndarray, title: str) -> None: + image = axis.imshow(values.T, origin="lower", cmap="coolwarm", vmin=-1.0, vmax=1.0) + axis.set_title(title) + axis.set_xlabel("i") + axis.set_ylabel("j") + plt.colorbar(image, ax=axis, fraction=0.046, pad=0.04) + + +def _plot_mask(axis: _AxisLike, values: np.ndarray, title: str) -> None: + axis.imshow(values.T, origin="lower", cmap="gray", vmin=0.0, vmax=1.0) + axis.set_title(title) + axis.set_xlabel("x") + axis.set_ylabel("y") + + +def _plot_weight(axis: _AxisLike, values: np.ndarray, title: str) -> None: + image = axis.imshow(values.T, origin="lower", cmap="viridis") + axis.set_title(title) + axis.set_xlabel("x") + axis.set_ylabel("y") + plt.colorbar(image, ax=axis, fraction=0.046, pad=0.04) + + def _safe_prefix(prefix: str) -> str: return re.sub(r"[^A-Za-z0-9_.-]+", "_", prefix).strip("_") or "tsdf" diff --git a/dimos/robot/manipulators/xarm/blueprints/simulation.py b/dimos/robot/manipulators/xarm/blueprints/simulation.py index b57ece52e9..fa246f2905 100644 --- a/dimos/robot/manipulators/xarm/blueprints/simulation.py +++ b/dimos/robot/manipulators/xarm/blueprints/simulation.py @@ -104,11 +104,15 @@ ), VGNGraspGenModule.blueprint( output_frame="world", + quality_threshold=0.05, + width_filter_min_voxels=0.0, + width_filter_max_voxels=1000.0, + filter_candidates_to_target_bounds=True, auto_generate_on_tsdf=False, debug_export_dir="/tmp/opencode/dimos-vgn-tsdf-debug", ), GraspingModule.blueprint(), - TargetGraspDemoController.blueprint(target_name="orange"), + TargetGraspDemoController.blueprint(target_name="sphere", cushion_m=0.2), RerunBridgeModule.blueprint(), ).remappings( [ diff --git a/dimos/robot/manipulators/xarm/blueprints/test_vgn_mujoco_grasp_demo.py b/dimos/robot/manipulators/xarm/blueprints/test_vgn_mujoco_grasp_demo.py index 1ea4c4a4db..45b08a632d 100644 --- a/dimos/robot/manipulators/xarm/blueprints/test_vgn_mujoco_grasp_demo.py +++ b/dimos/robot/manipulators/xarm/blueprints/test_vgn_mujoco_grasp_demo.py @@ -65,8 +65,12 @@ def test_vgn_mujoco_grasp_demo_uses_target_controller_not_workspace_auto_generat ) assert grasp_atom.kwargs["auto_generate_on_tsdf"] is False + assert grasp_atom.kwargs["quality_threshold"] == 0.05 + assert grasp_atom.kwargs["width_filter_max_voxels"] == 1000.0 + assert grasp_atom.kwargs["filter_candidates_to_target_bounds"] is True assert grasp_atom.kwargs["debug_export_dir"] == "/tmp/opencode/dimos-vgn-tsdf-debug" - assert controller_atom.kwargs["target_name"] == "orange" + assert controller_atom.kwargs["target_name"] == "sphere" + assert controller_atom.kwargs["cushion_m"] == 0.2 assert grasp_atom.kwargs.get("auto_generate_on_tsdf") is False assert reconstruction_atom.kwargs["workspace_center"] == (0.45, 0.0, 0.18) assert reconstruction_atom.kwargs["resolution"] == 40 @@ -74,6 +78,7 @@ def test_vgn_mujoco_grasp_demo_uses_target_controller_not_workspace_auto_generat sim_atom = next( atom for atom in vgn_mujoco_grasp_demo.blueprints if atom.module is MujocoSimModule ) + assert sim_atom.kwargs["headless"] is False assert sim_atom.kwargs["initial_joint_positions"] == [ 0.0, -0.247, diff --git a/docs/usage/vgn_mujoco_grasp_demo.md b/docs/usage/vgn_mujoco_grasp_demo.md index f0d40dadfd..d45cb9a148 100644 --- a/docs/usage/vgn_mujoco_grasp_demo.md +++ b/docs/usage/vgn_mujoco_grasp_demo.md @@ -31,6 +31,6 @@ The default target prompt/name is `orange`, the sphere near the workspace center The TSDF visualizations are diagnostic voxel views, not watertight meshes. `world/tsdf_surface` and `world/target_masked_tsdf` render near-surface TSDF voxels as points. They can look blocky at the demo resolution, and the masked TSDF is expected to look cropped because voxels outside the cushioned target bounds are suppressed before VGN inference. -For offline inspection, the demo writes TSDF debug artifacts to `/tmp/opencode/dimos-vgn-tsdf-debug/` whenever target-conditioned generation runs. Each run exports raw `.npz` grids plus Open3D-readable `.ply` point clouds for near-surface voxels and observed voxels, for both the full TSDF and target-masked TSDF. +For offline inspection, the demo writes TSDF debug artifacts to `/tmp/opencode/dimos-vgn-tsdf-debug/` whenever target-conditioned generation runs. Each run exports raw `.npz` grids, Open3D-readable `.ply` point clouds for near-surface and observed voxels, `.png` slice/projection summaries, and `.json` count/stat summaries for both the full TSDF and target-masked TSDF. The near-surface views use normalized TSDF values with `abs(distance) <= 0.1`; they are diagnostic zero-crossing bands, not metric-distance meshes. If no checkpoint path is set, the VGN module starts but reports a clear `DIMOS_VGN_MODEL_PATH` requirement when target-conditioned grasp generation runs. From b18bb7d507ffee238963653c06ec1439c8c4f0ef Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 14:44:53 -0700 Subject: [PATCH 083/110] feat: add robosuite runtime sidecar --- .gitignore | 3 + CONTEXT.md | 128 +++ dimos/benchmark/runtime/__init__.py | 1 + dimos/benchmark/runtime/artifacts.py | 32 + dimos/benchmark/runtime/config.py | 100 +++ .../runtime/configs/fake_runtime_smoke.json | 14 + .../runtime/configs/robosuite_panda_lift.json | 19 + dimos/benchmark/runtime/test_config.py | 73 ++ .../runtime/test_protocol_import_boundary.py | 40 + .../runtime/test_robosuite_sidecar_profile.py | 226 ++++++ .../runtime/test_sidecar_import_boundaries.py | 47 ++ .../whole_body/benchmark_runtime/adapter.py | 105 +++ dimos/simulation/runtime_client/__init__.py | 1 + .../simulation/runtime_client/http_client.py | 100 +++ dimos/simulation/runtime_client/shm_motor.py | 235 ++++++ .../runtime_client/test_http_client.py | 35 + .../runtime_client/test_shm_motor.py | 43 ++ docs/development/runtime_sidecars.md | 139 ++++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../benchmark-prelaunch-orchestration/spec.md | 0 .../specs/robosuite-runtime-sidecar/spec.md | 2 +- .../specs/runtime-sidecar-protocol/spec.md | 4 + .../specs/scripted-runtime-demos/spec.md | 8 + .../tasks.md | 74 +- .../benchmark-prelaunch-orchestration/spec.md | 59 ++ .../specs/robosuite-runtime-sidecar/spec.md | 51 ++ .../specs/runtime-sidecar-protocol/spec.md | 56 ++ openspec/specs/scripted-runtime-demos/spec.md | 53 ++ .../dimos-fake-runtime-sidecar/pyproject.toml | 18 + .../dimos_fake_runtime_sidecar/__init__.py | 1 + .../src/dimos_fake_runtime_sidecar/server.py | 204 +++++ .../dimos-robosuite-sidecar/pyproject.toml | 21 + .../src/dimos_robosuite_sidecar/__init__.py | 1 + .../src/dimos_robosuite_sidecar/server.py | 557 +++++++++++++ .../dimos-runtime-protocol/pyproject.toml | 20 + .../src/dimos_runtime_protocol/__init__.py | 46 ++ .../src/dimos_runtime_protocol/codecs.py | 67 ++ .../src/dimos_runtime_protocol/compat.py | 74 ++ .../src/dimos_runtime_protocol/models.py | 220 ++++++ .../src/dimos_runtime_protocol/types.py | 26 + .../src/dimos_runtime_protocol/version.py | 17 + .../tests/test_models.py | 86 +++ pyproject.toml | 9 +- .../benchmarks/demo_fake_runtime_sidecar.py | 262 +++++++ scripts/benchmarks/demo_rerun_color_smoke.py | 152 ++++ .../demo_robosuite_camera_payload_smoke.py | 320 ++++++++ .../benchmarks/demo_robosuite_panda_lift.py | 730 ++++++++++++++++++ uv.lock | 5 +- 50 files changed, 4441 insertions(+), 43 deletions(-) create mode 100644 dimos/benchmark/runtime/__init__.py create mode 100644 dimos/benchmark/runtime/artifacts.py create mode 100644 dimos/benchmark/runtime/config.py create mode 100644 dimos/benchmark/runtime/configs/fake_runtime_smoke.json create mode 100644 dimos/benchmark/runtime/configs/robosuite_panda_lift.json create mode 100644 dimos/benchmark/runtime/test_config.py create mode 100644 dimos/benchmark/runtime/test_protocol_import_boundary.py create mode 100644 dimos/benchmark/runtime/test_robosuite_sidecar_profile.py create mode 100644 dimos/benchmark/runtime/test_sidecar_import_boundaries.py create mode 100644 dimos/hardware/whole_body/benchmark_runtime/adapter.py create mode 100644 dimos/simulation/runtime_client/__init__.py create mode 100644 dimos/simulation/runtime_client/http_client.py create mode 100644 dimos/simulation/runtime_client/shm_motor.py create mode 100644 dimos/simulation/runtime_client/test_http_client.py create mode 100644 dimos/simulation/runtime_client/test_shm_motor.py create mode 100644 docs/development/runtime_sidecars.md rename openspec/changes/{framework-robosuite-integration => archive/2026-06-26-framework-robosuite-integration}/.openspec.yaml (100%) rename openspec/changes/{framework-robosuite-integration => archive/2026-06-26-framework-robosuite-integration}/design.md (100%) rename openspec/changes/{framework-robosuite-integration => archive/2026-06-26-framework-robosuite-integration}/proposal.md (100%) rename openspec/changes/{framework-robosuite-integration => archive/2026-06-26-framework-robosuite-integration}/specs/benchmark-prelaunch-orchestration/spec.md (100%) rename openspec/changes/{framework-robosuite-integration => archive/2026-06-26-framework-robosuite-integration}/specs/robosuite-runtime-sidecar/spec.md (95%) rename openspec/changes/{framework-robosuite-integration => archive/2026-06-26-framework-robosuite-integration}/specs/runtime-sidecar-protocol/spec.md (92%) rename openspec/changes/{framework-robosuite-integration => archive/2026-06-26-framework-robosuite-integration}/specs/scripted-runtime-demos/spec.md (77%) rename openspec/changes/{framework-robosuite-integration => archive/2026-06-26-framework-robosuite-integration}/tasks.md (60%) create mode 100644 openspec/specs/benchmark-prelaunch-orchestration/spec.md create mode 100644 openspec/specs/robosuite-runtime-sidecar/spec.md create mode 100644 openspec/specs/runtime-sidecar-protocol/spec.md create mode 100644 openspec/specs/scripted-runtime-demos/spec.md create mode 100644 packages/dimos-fake-runtime-sidecar/pyproject.toml create mode 100644 packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/__init__.py create mode 100644 packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/server.py create mode 100644 packages/dimos-robosuite-sidecar/pyproject.toml create mode 100644 packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/__init__.py create mode 100644 packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/server.py create mode 100644 packages/dimos-runtime-protocol/pyproject.toml create mode 100644 packages/dimos-runtime-protocol/src/dimos_runtime_protocol/__init__.py create mode 100644 packages/dimos-runtime-protocol/src/dimos_runtime_protocol/codecs.py create mode 100644 packages/dimos-runtime-protocol/src/dimos_runtime_protocol/compat.py create mode 100644 packages/dimos-runtime-protocol/src/dimos_runtime_protocol/models.py create mode 100644 packages/dimos-runtime-protocol/src/dimos_runtime_protocol/types.py create mode 100644 packages/dimos-runtime-protocol/src/dimos_runtime_protocol/version.py create mode 100644 packages/dimos-runtime-protocol/tests/test_models.py create mode 100644 scripts/benchmarks/demo_fake_runtime_sidecar.py create mode 100644 scripts/benchmarks/demo_rerun_color_smoke.py create mode 100644 scripts/benchmarks/demo_robosuite_camera_payload_smoke.py create mode 100644 scripts/benchmarks/demo_robosuite_panda_lift.py diff --git a/.gitignore b/.gitignore index be5fad50c8..832f136c7c 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,6 @@ recording*.db # Deepwork local progress state .slim/deepwork/ + +# Benchmark demo output artifacts +artifacts/ diff --git a/CONTEXT.md b/CONTEXT.md index aaa42747a6..75ff5b250b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -28,6 +28,98 @@ _Avoid_: autonomous simulator loop, write-triggered stepping, settle step A separate process or environment that owns a benchmark simulator backend while DimOS owns orchestration, control integration, skills, and artifacts. _Avoid_: plugin, embedded simulator +**Remote module worker**: +A separate Python environment that hosts first-class DimOS Modules while the main DimOS process owns blueprint orchestration and module coordination. +_Avoid_: arbitrary sidecar service, embedded optional dependency + +**Venv module worker**: +A same-machine Python virtual environment that hosts first-class DimOS Modules separately from the main DimOS Python environment. +_Avoid_: remote deployment, sidecar service, optional dependency import guard + +**Module import descriptor**: +A portable identity for a DimOS Module class that a venv module worker can import in its own Python environment. +_Avoid_: pickled module class, shared interpreter object, source checkout assumption + +**Module placement**: +A blueprint-level decision that chooses where a DimOS Module instance runs, such as the default worker pool or a named venv module worker. +_Avoid_: intrinsic module type, stream transport, permanent class identity + +**Local worker control channel**: +A same-machine private channel used by the coordinator process to send lifecycle and wiring commands to a worker process. +_Avoid_: stream data plane, remote public API, transport topic + +**Multiprocessing connection control channel**: +A local worker control channel implemented with Python's multiprocessing connection Listener/Client machinery so separately launched Python interpreters can exchange DimOS worker protocol messages. +_Avoid_: stream transport, stdio protocol, remote deployment API + +**Named venv**: +A runtime-resolved label for a Python virtual environment that can host venv module workers. +_Avoid_: hardcoded interpreter path in blueprint, deployment target, stream transport + +**Worker protocol runtime**: +The DimOS runtime surface that a worker environment must provide to receive coordinator lifecycle commands and host first-class Modules. +_Avoid_: full optional dependency set, application module package, public remote API + +**Module contract**: +A lightweight coordinator-visible DimOS Module class that declares the streams, module references, RPC surface, and config expectations used for blueprint wiring. +_Avoid_: heavy implementation module, connection-only shim, runtime sidecar + +**Module implementation descriptor**: +A portable identity for the concrete Module implementation class that a venv module worker imports and instantiates for a module contract. +_Avoid_: coordinator-imported heavy class, pickled module class, alternate stream contract + +**Contract compatibility responsibility**: +The phase-1 expectation that a module implementation descriptor names a concrete Module compatible with its coordinator-visible module contract, without an explicit verifier. +_Avoid_: schema validation requirement, subclass proof, remote API compatibility guarantee + +**Venv worker failure semantics**: +The phase-1 rule that an incompatible or failing venv module implementation fails the normal blueprint build lifecycle instead of degrading into a partial system. +_Avoid_: optional module fallback, background retry policy, partial blueprint success + +**Venv module placement API**: +A blueprint-level mapping that runs selected Python module contracts in named venvs using concrete module implementation descriptors. +_Avoid_: new Module base class, permanent class deployment attribute, global transport setting + +**Python venv placement**: +A mapping entry keyed by the coordinator-visible module contract class and valued by a named venv plus implementation descriptor. +_Avoid_: deployment type, stream transport key, hardcoded worker process + +**Reserved deploy kwargs**: +Internal coordinator-to-worker-manager metadata carried through module deploy kwargs during early architecture spikes and stripped before Module instantiation. +_Avoid_: user module config, public constructor argument, permanent ModuleSpec schema + +**Venv worker environment config**: +A runtime mapping from a named venv to an existing Python executable that can launch venv module workers. +_Avoid_: automatic venv creation, package installation plan, blueprint-embedded filesystem path + +**Runtime environment registry**: +A runtime configuration map from stable environment names to environment backends that resolve interpreters, executables, command environment variables, and optional preparation steps for DimOS-managed processes. +_Avoid_: venv-only config, blueprint-embedded machine paths, per-module ad hoc build commands + +**Named runtime environment**: +A stable label in the runtime environment registry that modules and worker placements reference when they need a non-default execution environment. +_Avoid_: hardcoded venv path, Nix command string as identity, deployment type + +**Named venv worker pool**: +A set of worker processes launched from the same named venv that may host multiple compatible placed Modules without mixing Modules assigned to other Python environments. +_Avoid_: one process per Module, shared cross-venv pool, global worker pool replacement + +**Worker launch strategy**: +The mechanism used to start and connect a worker process while preserving the shared worker pool and runtime protocol architecture. +_Avoid_: duplicated worker scheduler, separate venv-only runtime, stream transport selection + +**Worker process handle**: +A coordinator-side abstraction for a running worker process that supports module deployment, lifecycle requests, capacity accounting, and shutdown regardless of how the process was launched. +_Avoid_: launch mechanism, worker scheduler, module implementation + +**Worker launcher**: +A strategy object that creates worker process handles for a specific Python launch environment, such as the coordinator venv or a named venv. +_Avoid_: pool manager, module deployer, transport factory + +**Import-safe module file**: +A Python file defining a venv-deployable DimOS Module that can be imported by the coordinator environment without importing worker-only optional dependencies at module import time. +_Avoid_: split package requirement, top-level heavy dependency import, hidden sidecar boundary + **Depth observation**: A depth image interpreted with its camera calibration and the pose of its camera frame at the image timestamp. _Avoid_: depth point cloud, raw 3D points @@ -68,6 +160,42 @@ _Avoid_: backend SDK type, DimOS hardware adapter type, simulator object A lightweight installable package in the monorepo that contains only remote runtime protocol schemas, codecs, and compatibility tests so sidecars can depend on it without installing DimOS. _Avoid_: DimOS submodule, simulator backend package, hardware adapter package +**Runtime observation stream**: +A simulator-derived observation exposed through DimOS's normal typed stream contracts so visualization, agents, and evaluators can consume the same observation path. +_Avoid_: artifact-only observation, sidecar metadata, viewer shortcut + +**Runtime payload reference**: +A protocol observation field that names retrievable binary observation data, allowing step responses to carry metadata while clients fetch image, depth, or segmentation payloads separately. +_Avoid_: inline base64 image, local file path contract, remote shared memory + +**Array-native observation payload**: +A runtime observation payload that preserves array shape, dtype, and values across the remote runtime boundary without image compression semantics. +_Avoid_: JPEG-first image transport, display-only frame, encoded screenshot + +**Runtime observation module**: +A DimOS module that turns remote runtime observation metadata and payload references into normal typed DimOS streams. +_Avoid_: demo script publisher, sidecar-owned DimOS stream, artifact replay + +**Step-synchronized observation**: +A runtime observation publication policy where images and camera metadata are emitted from the same episode step that produced the motor state, score metadata, and protocol trace. +_Avoid_: independent polling frame, unsynchronized viewer feed, latest-only observation + +**Script-hosted runtime demo**: +A plain Python demo that orchestrates sidecar startup, runtime stepping, local control plumbing, and visualization without becoming a product CLI or benchmark runner. +_Avoid_: production runner, DimOS CLI command, artifact-only smoke test + +**Rerun runtime demo**: +A script-hosted runtime demo mode that publishes simulator observations into Rerun through DimOS observation streams for visual inspection. +_Avoid_: simulator viewer shortcut, saved-image artifact, direct Rerun-only bypass + +**Stream-backed runtime visualization**: +A visualization path where runtime observations are rendered by consumers of DimOS streams, not by calling a visualization SDK directly at the runtime boundary. +_Avoid_: direct Rerun log call, viewer-only proof, stream bypass + +**Canonical demo camera topics**: +The first runtime camera visualization demo publishes a single RGB image and camera model on the conventional `color_image` and `camera_info` topics. +_Avoid_: demo-only topic namespace, artifact image, direct viewer entity + **Damiao-based Robot**: A robot whose joints are actuated by one or more Damiao motors, possibly spread across multiple CAN buses and physical limbs. _Avoid_: Damiao arm when the robot may contain multiple motor groups diff --git a/dimos/benchmark/runtime/__init__.py b/dimos/benchmark/runtime/__init__.py new file mode 100644 index 0000000000..1ee3031b0f --- /dev/null +++ b/dimos/benchmark/runtime/__init__.py @@ -0,0 +1 @@ +"""Benchmark runtime orchestration helpers.""" diff --git a/dimos/benchmark/runtime/artifacts.py b/dimos/benchmark/runtime/artifacts.py new file mode 100644 index 0000000000..06328b60f6 --- /dev/null +++ b/dimos/benchmark/runtime/artifacts.py @@ -0,0 +1,32 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Small artifact helpers for runtime demos.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def write_json(path: Path, payload: object) -> None: + """Write JSON artifact with parent directory creation.""" + + path.parent.mkdir(parents=True, exist_ok=True) + model_dump = getattr(payload, "model_dump", None) + if callable(model_dump): + value = model_dump(mode="json") + else: + value = payload + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") diff --git a/dimos/benchmark/runtime/config.py b/dimos/benchmark/runtime/config.py new file mode 100644 index 0000000000..ca826831a2 --- /dev/null +++ b/dimos/benchmark/runtime/config.py @@ -0,0 +1,100 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Config and resolved plan models for benchmark runtime demos.""" + +from __future__ import annotations + +from pathlib import Path + +from dimos_runtime_protocol import RuntimeDescription, check_compatible +from pydantic import BaseModel, ConfigDict, PositiveInt + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class BenchmarkEpisodeConfig(StrictModel): + """Backend-facing declaration of one benchmark episode intent.""" + + backend: str = "fake" + episode_id: str = "fake-smoke" + task_id: str = "fake-motor-smoke" + runtime_host: str = "127.0.0.1" + runtime_port: PositiveInt = 8765 + robot_id: str = "fakebot" + dof: PositiveInt = 3 + control_step_hz: PositiveInt = 100 + ticks: PositiveInt = 30 + target_position: float = 0.2 + seed: int | None = None + artifact_dir: Path = Path("artifacts/benchmark/fake-runtime-smoke") + env_name: str = "Lift" + robot_model: str = "Panda" + controller: str = "JOINT_POSITION" + horizon: PositiveInt = 200 + camera_name: str = "agentview" + visualize: bool = False + + +class ResolvedRuntimePlan(StrictModel): + """Concrete launch material derived from a benchmark episode config.""" + + episode_id: str + task_id: str + backend: str + runtime_base_url: str + shm_key: str + robot_id: str + motor_names: list[str] + control_step_hz: PositiveInt + ticks: PositiveInt + target_position: float + artifact_dir: Path + + +def resolve_runtime_plan( + config: BenchmarkEpisodeConfig, + description: RuntimeDescription, +) -> ResolvedRuntimePlan: + """Validate sidecar metadata and derive a concrete runtime plan.""" + + compatibility = check_compatible(description.protocol) + if not compatibility.compatible: + raise ValueError(f"incompatible sidecar protocol: {compatibility.reason}") + if description.backend != config.backend: + raise ValueError(f"backend mismatch: config={config.backend} sidecar={description.backend}") + matching_surfaces = [ + surface for surface in description.robot_surfaces if surface.robot_id == config.robot_id + ] + if not matching_surfaces: + raise ValueError(f"sidecar did not report robot surface {config.robot_id!r}") + surface = matching_surfaces[0] + motor_names = [motor.name for motor in sorted(surface.motors, key=lambda motor: motor.index)] + if len(motor_names) != config.dof: + raise ValueError(f"expected {config.dof} motors, sidecar reported {len(motor_names)}") + return ResolvedRuntimePlan( + episode_id=config.episode_id, + task_id=config.task_id, + backend=config.backend, + runtime_base_url=f"http://{config.runtime_host}:{config.runtime_port}", + shm_key=f"{config.episode_id}-{config.robot_id}", + robot_id=config.robot_id, + motor_names=motor_names, + control_step_hz=config.control_step_hz, + ticks=config.ticks, + target_position=config.target_position, + artifact_dir=config.artifact_dir, + ) diff --git a/dimos/benchmark/runtime/configs/fake_runtime_smoke.json b/dimos/benchmark/runtime/configs/fake_runtime_smoke.json new file mode 100644 index 0000000000..618decc299 --- /dev/null +++ b/dimos/benchmark/runtime/configs/fake_runtime_smoke.json @@ -0,0 +1,14 @@ +{ + "backend": "fake", + "episode_id": "fake-runtime-smoke", + "task_id": "fake-motor-smoke", + "runtime_host": "127.0.0.1", + "runtime_port": 8765, + "robot_id": "fakebot", + "dof": 3, + "control_step_hz": 100, + "ticks": 30, + "target_position": 0.2, + "seed": 7, + "artifact_dir": "artifacts/benchmark/fake-runtime-smoke" +} diff --git a/dimos/benchmark/runtime/configs/robosuite_panda_lift.json b/dimos/benchmark/runtime/configs/robosuite_panda_lift.json new file mode 100644 index 0000000000..685b67cb16 --- /dev/null +++ b/dimos/benchmark/runtime/configs/robosuite_panda_lift.json @@ -0,0 +1,19 @@ +{ + "backend": "robosuite", + "episode_id": "robosuite-panda-lift", + "task_id": "Lift", + "runtime_host": "127.0.0.1", + "runtime_port": 8766, + "robot_id": "panda", + "dof": 8, + "control_step_hz": 100, + "ticks": 30, + "target_position": 0.05, + "seed": 7, + "artifact_dir": "artifacts/benchmark/robosuite-panda-lift", + "env_name": "Lift", + "robot_model": "Panda", + "controller": "JOINT_POSITION", + "horizon": 200, + "camera_name": "agentview" +} diff --git a/dimos/benchmark/runtime/test_config.py b/dimos/benchmark/runtime/test_config.py new file mode 100644 index 0000000000..1548c59006 --- /dev/null +++ b/dimos/benchmark/runtime/test_config.py @@ -0,0 +1,73 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for benchmark runtime config resolution.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +sys.path.insert(0, str(PROTOCOL_SRC)) + +from dimos_runtime_protocol import ( + MotorDescription, + ProtocolVersion, + RobotMotorSurface, + RuntimeDescription, +) + +from dimos.benchmark.runtime.config import ( + BenchmarkEpisodeConfig, + resolve_runtime_plan, +) + + +def test_resolve_runtime_plan_rejects_incompatible_protocol() -> None: + description = _description(protocol=ProtocolVersion(version="1.0", min_compatible="1.0")) + + with pytest.raises(ValueError, match="incompatible sidecar protocol"): + resolve_runtime_plan(BenchmarkEpisodeConfig(), description) + + +def test_resolve_runtime_plan_rejects_robot_profile_mismatch() -> None: + description = _description(robot_id="otherbot") + + with pytest.raises(ValueError, match="sidecar did not report robot surface"): + resolve_runtime_plan(BenchmarkEpisodeConfig(), description) + + +def _description( + *, + robot_id: str = "fakebot", + protocol: ProtocolVersion | None = None, +) -> RuntimeDescription: + return RuntimeDescription( + runtime_id="fake-runtime", + backend="fake", + protocol=protocol or ProtocolVersion(), + robot_surfaces=[ + RobotMotorSurface( + robot_id=robot_id, + motors=[ + MotorDescription(name=f"{robot_id}/joint{i + 1}", index=i) for i in range(3) + ], + ) + ], + control_step_hz=100, + ) diff --git a/dimos/benchmark/runtime/test_protocol_import_boundary.py b/dimos/benchmark/runtime/test_protocol_import_boundary.py new file mode 100644 index 0000000000..d0e9b38033 --- /dev/null +++ b/dimos/benchmark/runtime/test_protocol_import_boundary.py @@ -0,0 +1,40 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +import subprocess +import sys + + +def test_runtime_protocol_import_does_not_import_dimos_or_backends() -> None: + repo = Path(__file__).resolve().parents[3] + protocol_src = repo / "packages" / "dimos-runtime-protocol" / "src" + code = f""" +import sys +sys.path.insert(0, r'{protocol_src}') +import dimos_runtime_protocol +for name in ('dimos', 'robosuite', 'libero', 'omnigibson'): + if name in sys.modules: + raise SystemExit(f'{{name}} was imported') +print(dimos_runtime_protocol.PROTOCOL_VERSION) +""" + + result = subprocess.run( + [sys.executable, "-c", code], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() diff --git a/dimos/benchmark/runtime/test_robosuite_sidecar_profile.py b/dimos/benchmark/runtime/test_robosuite_sidecar_profile.py new file mode 100644 index 0000000000..a2c5c7294c --- /dev/null +++ b/dimos/benchmark/runtime/test_robosuite_sidecar_profile.py @@ -0,0 +1,226 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Profile tests for the narrow Robosuite sidecar mapping.""" + +from __future__ import annotations + +from collections.abc import Sequence +import importlib.util +from io import BytesIO +from pathlib import Path +import sys +from types import ModuleType +from typing import Protocol, cast + +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[3] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +ROBOSUITE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src" +sys.path.insert(0, str(PROTOCOL_SRC)) +sys.path.insert(0, str(ROBOSUITE_SIDECAR_SRC)) + +from dimos_robosuite_sidecar.server import ( + RobosuiteRuntimeConfig, + RobosuiteRuntimeState, +) +from dimos_runtime_protocol import EpisodeResetRequest, MotorActionFrame, StepRequest + +DEMO_SCRIPT = REPO_ROOT / "scripts" / "benchmarks" / "demo_robosuite_panda_lift.py" +_DEMO_SPEC = importlib.util.spec_from_file_location("demo_robosuite_panda_lift", DEMO_SCRIPT) +assert _DEMO_SPEC is not None +assert _DEMO_SPEC.loader is not None +_DEMO_MODULE = importlib.util.module_from_spec(_DEMO_SPEC) +sys.modules[_DEMO_SPEC.name] = _DEMO_MODULE +_DEMO_SPEC.loader.exec_module(_DEMO_MODULE) + + +class _PublishRerunObservations(Protocol): + def __call__(self, client: object, response_observations: object, publisher: object) -> int: ... + + +_publish_rerun_observations = cast( + "_PublishRerunObservations", + cast("ModuleType", _DEMO_MODULE).__dict__["_publish_rerun_observations"], +) + + +class _FakeControllers: + def load_composite_controller_config(self, *, controller: str) -> dict[str, object]: + return {"type": controller, "body_parts": {"right": {"type": "OSC_POSE"}}} + + def load_part_controller_config(self, *, default_controller: str) -> dict[str, str]: + return {"type": default_controller} + + +class _FakeEnv: + action_spec = ([-1.0] * 8, [1.0] * 8) + + def reset(self) -> dict[str, object]: + return _fake_obs([0.0] * 7, [0.0]) + + def step(self, action: object) -> tuple[dict[str, object], float, bool, dict[str, object]]: + action_values = [float(item) for item in cast("Sequence[float]", action)] + return _fake_obs(action_values[:7], [action_values[7]]), 1.0, False, {"success": True} + + +class _FakeRobosuiteModule: + controllers = _FakeControllers() + + class macros: + IMAGE_CONVENTION = "opengl" + + def make(self, **kwargs: object) -> _FakeEnv: + return _FakeEnv() + + +class _Mocker(Protocol): + def patch(self, target: str, **kwargs: object) -> object: ... + + +class _PayloadClient: + def __init__(self, state: RobosuiteRuntimeState) -> None: + self._state = state + + def payload(self, data_ref: str) -> bytes: + return self._state.payload_bytes(data_ref.removeprefix("/payloads/")) + + +class _CapturePublisher: + def __init__(self) -> None: + self.images: list[np.ndarray] = [] + self.fovs: list[float] = [] + self.frame_ids: list[str] = [] + + def publish_rgb(self, rgb: object, *, fov_y_deg: float, frame_id: str) -> None: + self.images.append(np.asarray(rgb).copy()) + self.fovs.append(fov_y_deg) + self.frame_ids.append(frame_id) + + +def test_robosuite_panda_lift_profile_maps_actions_states_and_observations( + mocker: _Mocker, +) -> None: + mocker.patch( + "dimos_robosuite_sidecar.server.require_robosuite", + return_value=_FakeRobosuiteModule(), + ) + state = RobosuiteRuntimeState(_config()) + + description = state.describe() + assert description.robot_surfaces[0].robot_id == "panda" + assert [motor.name for motor in description.robot_surfaces[0].motors] == [ + "panda/joint1", + "panda/joint2", + "panda/joint3", + "panda/joint4", + "panda/joint5", + "panda/joint6", + "panda/joint7", + "panda/gripper", + ] + + state.reset(EpisodeResetRequest(episode_id="episode", task_id="Lift")) + response = state.step( + StepRequest( + episode_id="episode", + tick_id=1, + action=MotorActionFrame( + robot_id="panda", + names=state.motor_names, + q=[0.1] * 8, + ), + ) + ) + + assert response.success is True + assert response.motor_state.names == state.motor_names + assert response.motor_state.q == [0.1] * 8 + image_frame = next(frame for frame in response.observations if frame.stream == "agentview") + assert image_frame.encoding == "npy" + assert image_frame.metadata["camera_name"] == "agentview" + assert image_frame.metadata["camera_mount"] == "scene" + assert image_frame.metadata["image_convention"] == "opengl" + assert image_frame.data_ref is not None + assert state.payload_bytes(image_frame.data_ref.removeprefix("/payloads/")) + assert {frame.stream for frame in response.observations} == {"robot_state", "agentview"} + + +def test_pure_color_camera_payload_round_trips_and_decodes_exactly(mocker: _Mocker) -> None: + mocker.patch( + "dimos_robosuite_sidecar.server.require_robosuite", + return_value=_FakeRobosuiteModule(), + ) + state = RobosuiteRuntimeState(_config()) + state.reset(EpisodeResetRequest(episode_id="episode", task_id="Lift")) + response = state.step( + StepRequest( + episode_id="episode", + tick_id=1, + action=MotorActionFrame( + robot_id="panda", + names=state.motor_names, + q=[0.0] * 8, + ), + ) + ) + + image_frame = next(frame for frame in response.observations if frame.stream == "agentview") + assert image_frame.data_ref is not None + payload = state.payload_bytes(image_frame.data_ref.removeprefix("/payloads/")) + raw = np.load(BytesIO(payload), allow_pickle=False) + assert np.array_equal(raw, _pure_color_image()) + + publisher = _CapturePublisher() + published = _publish_rerun_observations(_PayloadClient(state), response.observations, publisher) + + assert published == 1 + assert len(publisher.images) == 1 + assert np.array_equal(publisher.images[0], np.flipud(_pure_color_image())) + assert publisher.fovs == [45.0] + assert publisher.frame_ids == ["agentview"] + + +def _config() -> RobosuiteRuntimeConfig: + return RobosuiteRuntimeConfig( + host="127.0.0.1", + port=8766, + env_name="Lift", + robot_id="panda", + robot_model="Panda", + controller="JOINT_POSITION", + control_freq=100, + horizon=200, + camera_name="agentview", + seed=7, + ) + + +def _fake_obs(joint_q: list[float], gripper_q: list[float]) -> dict[str, object]: + return { + "robot0_joint_pos": joint_q, + "robot0_joint_vel": [0.0] * len(joint_q), + "robot0_gripper_qpos": gripper_q, + "robot0_gripper_qvel": [0.0] * len(gripper_q), + "agentview_image": _pure_color_image(), + } + + +def _pure_color_image() -> np.ndarray: + image = np.zeros((3, 2, 3), dtype=np.uint8) + image[0, :, :] = [255, 0, 0] + image[1, :, :] = [0, 255, 0] + image[2, :, :] = [0, 0, 255] + return image diff --git a/dimos/benchmark/runtime/test_sidecar_import_boundaries.py b/dimos/benchmark/runtime/test_sidecar_import_boundaries.py new file mode 100644 index 0000000000..f7b685fc92 --- /dev/null +++ b/dimos/benchmark/runtime/test_sidecar_import_boundaries.py @@ -0,0 +1,47 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Import-boundary tests for runtime sidecar packages.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + +REPO_ROOT = Path(__file__).resolve().parents[3] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +ROBOSUITE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src" + + +def test_robosuite_sidecar_import_does_not_import_heavy_backends_or_dimos() -> None: + script = """ +import importlib +import sys + +importlib.import_module('dimos_robosuite_sidecar.server') +for name in ('dimos', 'robosuite', 'libero', 'omnigibson'): + if name in sys.modules: + raise SystemExit(f'unexpected import: {name}') +""" + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + env={ + "PYTHONPATH": f"{PROTOCOL_SRC}:{ROBOSUITE_SIDECAR_SRC}", + }, + ) + assert result.returncode == 0, result.stderr or result.stdout diff --git a/dimos/hardware/whole_body/benchmark_runtime/adapter.py b/dimos/hardware/whole_body/benchmark_runtime/adapter.py new file mode 100644 index 0000000000..8ff08709d8 --- /dev/null +++ b/dimos/hardware/whole_body/benchmark_runtime/adapter.py @@ -0,0 +1,105 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""WholeBodyAdapter for benchmark runtime local SHM motor plane.""" + +from __future__ import annotations + +from pathlib import Path +import time + +from dimos.hardware.whole_body.registry import WholeBodyAdapterRegistry +from dimos.hardware.whole_body.spec import IMUState, MotorCommand, MotorState, WholeBodyAdapter +from dimos.simulation.runtime_client.shm_motor import MotorShmClient + + +class BenchmarkRuntimeWholeBodyAdapter(WholeBodyAdapter): + """Attach-only whole-body adapter backed by local benchmark runtime SHM.""" + + def __init__( + self, + *, + dof: int, + hardware_id: str, + address: str | Path | None = None, + domain_id: int = 0, + motor_names: list[str] | None = None, + connect_timeout_s: float = 2.0, + ) -> None: + self.dof = dof + self.hardware_id = hardware_id + self.address = str(address or hardware_id) + self.domain_id = domain_id + self.motor_names = motor_names or [f"{hardware_id}/joint{i + 1}" for i in range(dof)] + self.connect_timeout_s = connect_timeout_s + self._client: MotorShmClient | None = None + self._last_sequence = -1 + self._last_states: list[MotorState] = [MotorState() for _ in range(dof)] + + def connect(self) -> bool: + deadline = time.monotonic() + self.connect_timeout_s + last_error: FileNotFoundError | None = None + while time.monotonic() < deadline: + try: + self._client = MotorShmClient(self.address, self.motor_names) + return True + except FileNotFoundError as exc: + last_error = exc + time.sleep(0.01) + if last_error is not None: + return False + return False + + def disconnect(self) -> None: + if self._client is not None: + self._client.close() + self._client = None + + def is_connected(self) -> bool: + return self._client is not None + + def read_motor_states(self) -> list[MotorState]: + if self._client is None: + return self._last_states + sequence, states = self._client.read_states() + self._last_sequence = sequence + self._last_states = states + return states + + def has_motor_states(self) -> bool: + if self._client is None: + return False + try: + sequence, states = self._client.read_states() + except Exception: + return False + self._last_sequence = sequence + self._last_states = states + return len(states) == self.dof + + def read_imu(self) -> IMUState: + if self._client is None: + return IMUState() + return self._client.read_imu() + + def write_motor_commands(self, commands: list[MotorCommand]) -> bool: + if self._client is None: + return False + return self._client.write_commands(commands) + + +def register(registry: WholeBodyAdapterRegistry) -> None: + """Register benchmark runtime adapter.""" + + registry.register("benchmark_runtime", BenchmarkRuntimeWholeBodyAdapter) diff --git a/dimos/simulation/runtime_client/__init__.py b/dimos/simulation/runtime_client/__init__.py new file mode 100644 index 0000000000..fa1c2a1a6a --- /dev/null +++ b/dimos/simulation/runtime_client/__init__.py @@ -0,0 +1 @@ +"""DimOS-side runtime sidecar client utilities.""" diff --git a/dimos/simulation/runtime_client/http_client.py b/dimos/simulation/runtime_client/http_client.py new file mode 100644 index 0000000000..84b101b591 --- /dev/null +++ b/dimos/simulation/runtime_client/http_client.py @@ -0,0 +1,100 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Synchronous HTTP client for benchmark runtime sidecars.""" + +from __future__ import annotations + +import time + +from dimos_runtime_protocol import ( + EpisodeResetRequest, + EpisodeResetResponse, + HealthResponse, + RuntimeDescription, + ScoreOutput, + StepRequest, + StepResponse, +) +import requests + + +class RuntimeSidecarClient: + """Minimal request/response client for the v1 runtime sidecar protocol.""" + + def __init__(self, base_url: str, *, timeout_s: float = 5.0) -> None: + self.base_url = base_url.rstrip("/") + self.timeout_s = timeout_s + + def health(self) -> HealthResponse: + response = requests.get(f"{self.base_url}/health", timeout=self.timeout_s) + _raise_for_status(response) + return HealthResponse.model_validate(response.json()) + + def wait_until_healthy( + self, *, timeout_s: float = 10.0, poll_s: float = 0.05 + ) -> HealthResponse: + deadline = time.monotonic() + timeout_s + last_error: requests.RequestException | ValueError | None = None + while time.monotonic() < deadline: + try: + return self.health() + except (requests.RequestException, ValueError) as exc: + last_error = exc + time.sleep(poll_s) + raise TimeoutError(f"runtime sidecar did not become healthy: {last_error}") + + def describe(self) -> RuntimeDescription: + response = requests.get(f"{self.base_url}/describe", timeout=self.timeout_s) + _raise_for_status(response) + return RuntimeDescription.model_validate(response.json()) + + def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: + response = requests.post( + f"{self.base_url}/reset", + json=request.model_dump(mode="json"), + timeout=self.timeout_s, + ) + _raise_for_status(response) + return EpisodeResetResponse.model_validate(response.json()) + + def step(self, request: StepRequest) -> StepResponse: + response = requests.post( + f"{self.base_url}/step", + json=request.model_dump(mode="json"), + timeout=self.timeout_s, + ) + _raise_for_status(response) + return StepResponse.model_validate(response.json()) + + def score(self) -> ScoreOutput: + response = requests.get(f"{self.base_url}/score", timeout=self.timeout_s) + _raise_for_status(response) + return ScoreOutput.model_validate(response.json()) + + def payload(self, data_ref: str) -> bytes: + path = data_ref if data_ref.startswith("/") else f"/{data_ref}" + response = requests.get(f"{self.base_url}{path}", timeout=self.timeout_s) + _raise_for_status(response) + return response.content + + +def _raise_for_status(response: requests.Response) -> None: + try: + response.raise_for_status() + except requests.HTTPError as exc: + body = response.text.strip() + if body: + raise requests.HTTPError(f"{exc}; response body: {body}", response=response) from exc + raise diff --git a/dimos/simulation/runtime_client/shm_motor.py b/dimos/simulation/runtime_client/shm_motor.py new file mode 100644 index 0000000000..ae7e0a757f --- /dev/null +++ b/dimos/simulation/runtime_client/shm_motor.py @@ -0,0 +1,235 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Local shared-memory motor command/state bridge for benchmark runtimes.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from multiprocessing import shared_memory +import struct +import time +from typing import cast + +from dimos.hardware.whole_body.spec import POS_STOP, VEL_STOP, IMUState, MotorCommand, MotorState + +_HEADER = struct.Struct("!QI") +_DEFAULT_SIZE = 64 * 1024 +JsonPayload = dict[str, str | int | float | list[str] | list[float]] + + +@dataclass(frozen=True) +class MotorShmNames: + """Shared-memory block names for one runtime motor plane.""" + + command: str + state: str + + +def shm_names(key: str) -> MotorShmNames: + """Create deterministic local shared-memory names from a session key.""" + + safe = "".join(ch if ch.isalnum() else "_" for ch in key)[:80] + return MotorShmNames(command=f"dimos_rt_{safe}_cmd", state=f"dimos_rt_{safe}_state") + + +class JsonShmBlock: + """One fixed-size shared-memory JSON frame with sequence metadata.""" + + def __init__(self, name: str, *, create: bool, size: int = _DEFAULT_SIZE) -> None: + self.name = name + self._owner = create + self._shm = shared_memory.SharedMemory(name=name, create=create, size=size) + self._size = size + if create: + self.write({"sequence": 0}) + + def write(self, payload: JsonPayload) -> None: + sequence = _int_value(payload.get("sequence", 0)) + data = json.dumps(payload, separators=(",", ":")).encode("utf-8") + max_payload = self._size - _HEADER.size + if len(data) > max_payload: + raise ValueError( + f"payload too large for SHM block {self.name}: {len(data)}>{max_payload}" + ) + buf = cast("memoryview", self._shm.buf) + buf[: _HEADER.size] = _HEADER.pack(sequence, len(data)) + buf[_HEADER.size : _HEADER.size + len(data)] = data + + def read(self) -> JsonPayload: + buf = cast("memoryview", self._shm.buf) + sequence, length = _HEADER.unpack(bytes(buf[: _HEADER.size])) + if length == 0: + return {"sequence": sequence} + data = bytes(buf[_HEADER.size : _HEADER.size + length]) + value = json.loads(data.decode("utf-8")) + if not isinstance(value, dict): + raise ValueError(f"SHM block {self.name} did not contain a JSON object") + value["sequence"] = sequence + return value + + def close(self) -> None: + self._shm.close() + + def unlink(self) -> None: + if self._owner: + try: + self._shm.unlink() + except FileNotFoundError: + pass + + +class MotorShmOwner: + """Owner side of the local motor SHM plane. + + The DimOS runtime client creates/unlinks the blocks. The WholeBody adapter + attaches and never unlinks. + """ + + def __init__(self, key: str, motor_names: list[str], *, size: int = _DEFAULT_SIZE) -> None: + self.key = key + self.motor_names = motor_names + names = shm_names(key) + self.command = JsonShmBlock(names.command, create=True, size=size) + self.state = JsonShmBlock(names.state, create=True, size=size) + self.write_state([MotorState() for _ in motor_names], sequence=0) + self.write_commands([MotorCommand(q=0.0, dq=0.0) for _ in motor_names], sequence=0) + + def write_state(self, states: list[MotorState], *, sequence: int) -> None: + if len(states) != len(self.motor_names): + raise ValueError(f"expected {len(self.motor_names)} states, got {len(states)}") + self.state.write( + { + "sequence": sequence, + "timestamp_s": time.time(), + "names": self.motor_names, + "q": [state.q for state in states], + "dq": [state.dq for state in states], + "tau": [state.tau for state in states], + } + ) + + def read_commands(self) -> tuple[int, list[MotorCommand]]: + payload = self.command.read() + names = _list_str(payload.get("names", [])) + if names and names != self.motor_names: + raise ValueError(f"command names mismatch: expected {self.motor_names}, got {names}") + sequence = _int_value(payload.get("sequence", 0)) + q = _list_float(payload.get("q", []), len(self.motor_names), POS_STOP) + dq = _list_float(payload.get("dq", []), len(self.motor_names), VEL_STOP) + kp = _list_float(payload.get("kp", []), len(self.motor_names), 0.0) + kd = _list_float(payload.get("kd", []), len(self.motor_names), 0.0) + tau = _list_float(payload.get("tau", []), len(self.motor_names), 0.0) + return sequence, [ + MotorCommand(q=q[i], dq=dq[i], kp=kp[i], kd=kd[i], tau=tau[i]) + for i in range(len(self.motor_names)) + ] + + def write_commands(self, commands: list[MotorCommand], *, sequence: int) -> None: + if len(commands) != len(self.motor_names): + raise ValueError(f"expected {len(self.motor_names)} commands, got {len(commands)}") + self.command.write( + { + "sequence": sequence, + "timestamp_s": time.time(), + "names": self.motor_names, + "q": [command.q for command in commands], + "dq": [command.dq for command in commands], + "kp": [command.kp for command in commands], + "kd": [command.kd for command in commands], + "tau": [command.tau for command in commands], + } + ) + + def close(self) -> None: + self.command.close() + self.state.close() + + def unlink(self) -> None: + self.command.unlink() + self.state.unlink() + + +class MotorShmClient: + """Attach-only client used by the local WholeBodyAdapter.""" + + def __init__(self, key: str, motor_names: list[str], *, size: int = _DEFAULT_SIZE) -> None: + self.key = key + self.motor_names = motor_names + names = shm_names(key) + self.command = JsonShmBlock(names.command, create=False, size=size) + self.state = JsonShmBlock(names.state, create=False, size=size) + self._command_sequence = 0 + + def read_states(self) -> tuple[int, list[MotorState]]: + payload = self.state.read() + names = _list_str(payload.get("names", [])) + if names and names != self.motor_names: + raise ValueError(f"state names mismatch: expected {self.motor_names}, got {names}") + sequence = _int_value(payload.get("sequence", 0)) + q = _list_float(payload.get("q", []), len(self.motor_names), 0.0) + dq = _list_float(payload.get("dq", []), len(self.motor_names), 0.0) + tau = _list_float(payload.get("tau", []), len(self.motor_names), 0.0) + return sequence, [ + MotorState(q=q[i], dq=dq[i], tau=tau[i]) for i in range(len(self.motor_names)) + ] + + def write_commands(self, commands: list[MotorCommand]) -> bool: + if len(commands) != len(self.motor_names): + raise ValueError(f"expected {len(self.motor_names)} commands, got {len(commands)}") + self._command_sequence += 1 + self.command.write( + { + "sequence": self._command_sequence, + "timestamp_s": time.time(), + "names": self.motor_names, + "q": [command.q for command in commands], + "dq": [command.dq for command in commands], + "kp": [command.kp for command in commands], + "kd": [command.kd for command in commands], + "tau": [command.tau for command in commands], + } + ) + return True + + def read_imu(self) -> IMUState: + return IMUState() + + def close(self) -> None: + self.command.close() + self.state.close() + + +def _list_str(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value] + + +def _list_float(value: object, length: int, default: float) -> list[float]: + if not isinstance(value, list) or not value: + return [default] * length + result = [float(item) for item in value] + if len(result) != length: + raise ValueError(f"expected list length {length}, got {len(result)}") + return result + + +def _int_value(value: object) -> int: + if isinstance(value, bool): + return int(value) + if isinstance(value, int | float | str): + return int(value) + return 0 diff --git a/dimos/simulation/runtime_client/test_http_client.py b/dimos/simulation/runtime_client/test_http_client.py new file mode 100644 index 0000000000..cab4a7bcc7 --- /dev/null +++ b/dimos/simulation/runtime_client/test_http_client.py @@ -0,0 +1,35 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the synchronous runtime sidecar HTTP client.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +sys.path.insert(0, str(PROTOCOL_SRC)) + +from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient + + +def test_wait_until_healthy_times_out_for_unavailable_sidecar() -> None: + client = RuntimeSidecarClient("http://127.0.0.1:9", timeout_s=0.01) + + with pytest.raises(TimeoutError): + client.wait_until_healthy(timeout_s=0.02, poll_s=0.001) diff --git a/dimos/simulation/runtime_client/test_shm_motor.py b/dimos/simulation/runtime_client/test_shm_motor.py new file mode 100644 index 0000000000..255222e042 --- /dev/null +++ b/dimos/simulation/runtime_client/test_shm_motor.py @@ -0,0 +1,43 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.hardware.whole_body.benchmark_runtime.adapter import BenchmarkRuntimeWholeBodyAdapter +from dimos.hardware.whole_body.spec import MotorCommand, MotorState +from dimos.simulation.runtime_client.shm_motor import MotorShmOwner + + +def test_motor_shm_round_trip() -> None: + key = "test-runtime-shm-round-trip" + names = ["fakebot/joint1", "fakebot/joint2"] + owner = MotorShmOwner(key, names) + adapter = BenchmarkRuntimeWholeBodyAdapter( + dof=2, + hardware_id="fakebot", + address=key, + motor_names=names, + ) + try: + assert adapter.connect() + owner.write_state([MotorState(q=0.1), MotorState(q=0.2)], sequence=1) + states = adapter.read_motor_states() + assert [state.q for state in states] == [0.1, 0.2] + + assert adapter.write_motor_commands([MotorCommand(q=0.3), MotorCommand(q=0.4)]) + sequence, commands = owner.read_commands() + assert sequence == 1 + assert [command.q for command in commands] == [0.3, 0.4] + finally: + adapter.disconnect() + owner.close() + owner.unlink() diff --git a/docs/development/runtime_sidecars.md b/docs/development/runtime_sidecars.md new file mode 100644 index 0000000000..506b78a2e2 --- /dev/null +++ b/docs/development/runtime_sidecars.md @@ -0,0 +1,139 @@ +# Runtime sidecars + +DimOS benchmark runtime sidecars keep heavy simulator dependencies outside the +main DimOS environment while still exercising the real DimOS control path. + +## Boundaries + +- `packages/dimos-runtime-protocol` contains only Pydantic protocol schemas, + compatibility checks, and codecs. It must not import `dimos`, Robosuite, + LIBERO, or OmniGibson. +- Sidecar packages import `dimos_runtime_protocol` and their backend SDKs, but + not the main `dimos` package. +- The remote runtime boundary is synchronous HTTP in this slice: + `/health`, `/describe`, `/reset`, `/step`, `/score`, and referenced + observation payloads under `/payloads/{id}`. +- The Robosuite sidecar intentionally serves HTTP requests on one thread. + MuJoCo / Robosuite render contexts are thread-sensitive; keeping `reset`, + `step`, offscreen camera payload capture, and the interactive viewer on the + same server thread avoids corrupted camera frames in visual mode. +- The local motor data plane is OS shared memory between the DimOS runtime demo + code and the `benchmark_runtime` `WholeBodyAdapter`. SHM is not the remote + sidecar protocol. + +## Fake runtime smoke demo + +The fake demo requires no Robosuite installation and validates protocol, +sidecar startup, local SHM, `WholeBodyAdapter`, and real `ControlCoordinator` +wiring. + +```bash +PYTHONPATH="packages/dimos-runtime-protocol/src" \ + uv run python scripts/benchmarks/demo_fake_runtime_sidecar.py +``` + +Expected output includes `"ok": true` and artifacts under +`artifacts/benchmark/fake-runtime-smoke/`. + +## Robosuite Panda Lift plumbing demo + +Run this from an environment that can import Robosuite 1.5.x and this monorepo. +The DimOS process still does not import Robosuite; the sidecar subprocess owns +Robosuite environment construction and stepping. + +```bash +uv run --with robosuite python scripts/benchmarks/demo_robosuite_panda_lift.py +``` + +To open the Robosuite viewer and watch the Panda receive a longer moving command +sequence: + +```bash +uv run --with robosuite python scripts/benchmarks/demo_robosuite_panda_lift.py --visual +``` + +The visual mode enables Robosuite's on-screen renderer in the sidecar process, +runs at least 600 ticks, and sends an oscillating joint-position command through +the same `ControlCoordinator` → SHM → runtime sidecar path. It requires a local +display/GUI-capable environment. Visual mode uses Robosuite/MuJoCo free-camera +viewer mode, so the viewport can be changed interactively with the viewer mouse +controls while the scripted motion runs. The named `agentview` camera is still +used for protocol observation metadata. + +To verify the camera observation path through DimOS streams and Rerun, run: + +```bash +uv run --with robosuite python scripts/benchmarks/demo_robosuite_panda_lift.py --rerun +``` + +To verify the Robosuite camera payload path directly, without Rerun, run: + +```bash +uv run --with robosuite python scripts/benchmarks/demo_robosuite_camera_payload_smoke.py --ticks 2 +``` + +This starts the Robosuite sidecar, receives real Robosuite camera observation +frames, fetches each referenced `.npy` payload twice, decodes it with NumPy, and +asserts that the decoded array matches the sidecar-computed source hashes, +shape, dtype, and pixel summaries. Results are written to +`artifacts/benchmark/robosuite-camera-payload-smoke/camera_payload_smoke_summary.json`. +The same fetched/decoded images are also written as JPEGs under +`artifacts/benchmark/robosuite-camera-payload-smoke/images/`: `_raw.jpg` files +are the exact received arrays, and `_display_flipud.jpg` files apply the current +OpenGL display flip hypothesis for side-by-side inspection. + +If you need to verify the visualization transport independently of Robosuite, +run the deterministic color-bar smoke: + +```bash +uv run python scripts/benchmarks/demo_rerun_color_smoke.py +``` + +The smoke script writes `artifacts/benchmark/rerun-color-smoke/color_smoke_summary.json` +and publishes a fixed RGB image through the same `.npy` decode, DimOS `Image` +LCM encode/decode, and Rerun bridge path. The expected display is top red, +middle green, bottom blue, with no color changes over time. If this smoke test +looks wrong, debug the DimOS/Rerun visualization path before debugging +Robosuite camera payloads. + +`--rerun` keeps the Robosuite viewer optional. The sidecar returns `agentview` +observation frames with raw NumPy `.npy` payload references, the script fetches +those payloads from `/payloads/{id}`, decodes them, vertically flips Robosuite's +default OpenGL-convention images for normal image-display semantics, and +publishes a private demo `Image(format=RGB)` / `CameraInfo` stream pair through +DimOS transports. The Rerun bridge uses an isolated gRPC port and an isolated LCM +port by default, so repeated demo runs do not mix with older recordings or other +DimOS camera topics. The Rerun server/viewer memory cap defaults to `128MB`, and +image logging is throttled to `10Hz` to avoid unbounded raw-image memory use. +Override with `--rerun-memory-limit`, `--rerun-grpc-port`, `--rerun-lcm-port`, or +`--rerun-max-hz` when needed. When `--rerun` is enabled, the demo also writes +sampled fetched camera payloads as JPEGs under +`artifacts/benchmark/robosuite-panda-lift/images/`; `_raw.jpg` files are exact +decoded payload arrays and `_display.jpg` files apply the current display +transform. Control the sampling with `--camera-jpeg-dump-every N` (`1` dumps +every tick, `<=0` disables). Use `--visual --rerun` if you want both the simulator +viewer and the DimOS/Rerun stream view at the same time. + +`agentview` is a scene/task camera, not a wrist camera. To inspect a wrist-mounted +Panda camera instead, run: + +```bash +uv run --with robosuite python scripts/benchmarks/demo_robosuite_panda_lift.py --rerun --camera-name robot0_eye_in_hand +``` + +Useful Robosuite camera names for this demo include `agentview`, `frontview`, +`sideview`, `birdview`, `robot0_robotview`, and `robot0_eye_in_hand`. + +When `--visual --ticks N` is used, the script automatically raises the Robosuite +episode horizon to at least `N + 1`; otherwise long visual runs would hit the +default demo horizon and Robosuite would reject later `/step` calls after the env +terminates. You can still override this explicitly with `--horizon`. + +The demo uses `dimos/benchmark/runtime/configs/robosuite_panda_lift.json`, starts +`dimos_robosuite_sidecar.server`, resolves the Panda motor surface, builds a +Robosuite `Lift` + `Panda` env with a `JOINT_POSITION` arm controller plus +`GRIP`, runs a scripted joint-position target through `ControlCoordinator`, and +writes artifacts under `artifacts/benchmark/robosuite-panda-lift/`. + +If Robosuite is not installed, the script exits with an explicit sidecar health +failure and writes `robosuite_sidecar.log` with the import error. diff --git a/openspec/changes/framework-robosuite-integration/.openspec.yaml b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/.openspec.yaml similarity index 100% rename from openspec/changes/framework-robosuite-integration/.openspec.yaml rename to openspec/changes/archive/2026-06-26-framework-robosuite-integration/.openspec.yaml diff --git a/openspec/changes/framework-robosuite-integration/design.md b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/design.md similarity index 100% rename from openspec/changes/framework-robosuite-integration/design.md rename to openspec/changes/archive/2026-06-26-framework-robosuite-integration/design.md diff --git a/openspec/changes/framework-robosuite-integration/proposal.md b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/proposal.md similarity index 100% rename from openspec/changes/framework-robosuite-integration/proposal.md rename to openspec/changes/archive/2026-06-26-framework-robosuite-integration/proposal.md diff --git a/openspec/changes/framework-robosuite-integration/specs/benchmark-prelaunch-orchestration/spec.md b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/benchmark-prelaunch-orchestration/spec.md similarity index 100% rename from openspec/changes/framework-robosuite-integration/specs/benchmark-prelaunch-orchestration/spec.md rename to openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/benchmark-prelaunch-orchestration/spec.md diff --git a/openspec/changes/framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md similarity index 95% rename from openspec/changes/framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md rename to openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md index 23dd9ddfb9..4f9b193f37 100644 --- a/openspec/changes/framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md +++ b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/robosuite-runtime-sidecar/spec.md @@ -37,7 +37,7 @@ The Robosuite sidecar SHALL expose configured Robosuite camera and state observa #### Scenario: Agentview camera is available - **WHEN** the episode config enables the `agentview` camera -- **THEN** step responses include observation frames that allow DimOS to publish the camera output as a stream +- **THEN** step responses include observation frames with `.npy` payload references that allow DimOS to fetch and publish the camera output as a stream ### Requirement: Score and artifact export The Robosuite sidecar SHALL provide score and artifact outputs for each episode, including reward/done/success metadata, backend timing, and sidecar logs or trace summaries. diff --git a/openspec/changes/framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md similarity index 92% rename from openspec/changes/framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md rename to openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md index 6b8b6cfb14..33a74a9299 100644 --- a/openspec/changes/framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md +++ b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/runtime-sidecar-protocol/spec.md @@ -40,6 +40,10 @@ The runtime protocol SHALL support image, depth, segmentation, and object/state - **WHEN** a sidecar returns an RGB image observation - **THEN** the observation frame includes stream name, kind, encoding, shape, dtype, and either a binary payload reference or a supported binary payload representation +#### Scenario: Referenced array payload is fetched separately +- **WHEN** an observation frame reports a raw array payload reference +- **THEN** the DimOS runtime client can fetch the referenced `.npy` bytes without embedding image arrays in the JSON step response + ### Requirement: Backend-neutral protocol types Runtime protocol models MUST NOT expose Robosuite, LIBERO-PRO, OmniGibson, DimOS hardware adapter, or simulator object types in public fields. diff --git a/openspec/changes/framework-robosuite-integration/specs/scripted-runtime-demos/spec.md b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/scripted-runtime-demos/spec.md similarity index 77% rename from openspec/changes/framework-robosuite-integration/specs/scripted-runtime-demos/spec.md rename to openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/scripted-runtime-demos/spec.md index bed2f1b27a..8ebe03bd2e 100644 --- a/openspec/changes/framework-robosuite-integration/specs/scripted-runtime-demos/spec.md +++ b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/specs/scripted-runtime-demos/spec.md @@ -26,6 +26,14 @@ The system SHALL include a script-based Robosuite Panda Lift plumbing demo that - **WHEN** the Robosuite demo enables a camera observation stream - **THEN** DimOS-side artifacts or logs show that at least one observation frame was received from the sidecar and published or recorded by the runtime client +#### Scenario: Robosuite camera appears in Rerun through DimOS streams +- **WHEN** a developer runs the Robosuite demo with Rerun stream visualization enabled +- **THEN** the demo fetches referenced `.npy` camera payloads, applies the declared image convention, publishes `color_image` and `camera_info` through DimOS streams, and Rerun displays the camera image through its normal stream bridge rather than through direct Rerun SDK logging at the runtime boundary + +#### Scenario: Rerun demo stream is isolated and bounded +- **WHEN** a developer runs the Robosuite demo with Rerun stream visualization enabled repeatedly or alongside other DimOS publishers +- **THEN** the demo uses isolated Rerun and local DimOS transport settings for its visualization path and applies a bounded Rerun memory limit so old recordings or unrelated camera topics do not mix with the demo stream + ### Requirement: No agent success requirement The scripted demos SHALL verify runtime plumbing and MUST NOT require an LLM, MCP skill policy, or successful task completion by an agent. diff --git a/openspec/changes/framework-robosuite-integration/tasks.md b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/tasks.md similarity index 60% rename from openspec/changes/framework-robosuite-integration/tasks.md rename to openspec/changes/archive/2026-06-26-framework-robosuite-integration/tasks.md index 871fa902e6..4886a4b0a2 100644 --- a/openspec/changes/framework-robosuite-integration/tasks.md +++ b/openspec/changes/archive/2026-06-26-framework-robosuite-integration/tasks.md @@ -1,59 +1,61 @@ ## 1. Package and dependency boundaries -- [ ] 1.1 Create `packages/dimos-runtime-protocol` as a lightweight installable package with its own `pyproject.toml`, `src/`, and tests. -- [ ] 1.2 Create first-class sidecar package skeletons for `packages/dimos-robosuite-sidecar` and the fake/demo sidecar support without depending on the main `dimos` package. -- [ ] 1.3 Add optional dependency wiring or developer documentation so DimOS can install the runtime protocol package while Robosuite sidecar environments can install only the protocol plus sidecar package. -- [ ] 1.4 Add import-boundary tests that prove `dimos_runtime_protocol` imports without importing `dimos`, Robosuite, LIBERO-PRO, or OmniGibson. +- [x] 1.1 Create `packages/dimos-runtime-protocol` as a lightweight installable package with its own `pyproject.toml`, `src/`, and tests. +- [x] 1.2 Create first-class sidecar package skeletons for `packages/dimos-robosuite-sidecar` and the fake/demo sidecar support without depending on the main `dimos` package. +- [x] 1.3 Add optional dependency wiring or developer documentation so DimOS can install the runtime protocol package while Robosuite sidecar environments can install only the protocol plus sidecar package. +- [x] 1.4 Add import-boundary tests that prove `dimos_runtime_protocol` imports without importing `dimos`, Robosuite, LIBERO-PRO, or OmniGibson. ## 2. Runtime protocol -- [ ] 2.1 Define Pydantic protocol models for handshake, runtime description, episode reset, step request, step response, robot motor surfaces, motor action frames, motor state frames, observation frames, score output, artifact output, and protocol errors. -- [ ] 2.2 Add protocol version and capability compatibility checks used during sidecar handshake. -- [ ] 2.3 Implement binary-friendly codec helpers for protocol envelopes and small numeric arrays, with a path for image/depth payload references or binary payloads. -- [ ] 2.4 Add protocol validation tests for malformed step requests, incompatible versions, robot surface descriptions, and observation frame metadata. +- [x] 2.1 Define Pydantic protocol models for handshake, runtime description, episode reset, step request, step response, robot motor surfaces, motor action frames, motor state frames, observation frames, score output, artifact output, and protocol errors. +- [x] 2.2 Add protocol version and capability compatibility checks used during sidecar handshake. +- [x] 2.3 Implement binary-friendly codec helpers for protocol envelopes and small numeric arrays, with a path for image/depth payload references or binary payloads. +- [x] 2.4 Add protocol validation tests for malformed step requests, incompatible versions, robot surface descriptions, and observation frame metadata. ## 3. DimOS-side runtime client and local motor bridge -- [ ] 3.1 Implement a DimOS-side runtime client that connects to a sidecar endpoint, performs health/handshake, resets an episode, exchanges step frames, and retrieves score/artifact metadata. -- [ ] 3.2 Implement the local SHM motor bridge between the runtime client module and a WholeBodyAdapter-facing local motor data plane. -- [ ] 3.3 Implement or register a WholeBodyAdapter that reads `MotorState[]` and writes `MotorCommand[]` through the local SHM bridge for benchmark runtime use. -- [ ] 3.4 Add observation publishing hooks that translate protocol observation frames into DimOS streams or demo artifacts without exposing simulator SDK objects. -- [ ] 3.5 Add unit tests for local motor command/state round-trips and runtime client error handling. +- [x] 3.1 Implement a DimOS-side runtime client that connects to a sidecar endpoint, performs health/handshake, resets an episode, exchanges step frames, and retrieves score/artifact metadata. +- [x] 3.2 Implement the local SHM motor bridge between the runtime client module and a WholeBodyAdapter-facing local motor data plane. +- [x] 3.3 Implement or register a WholeBodyAdapter that reads `MotorState[]` and writes `MotorCommand[]` through the local SHM bridge for benchmark runtime use. +- [x] 3.4 Add observation publishing hooks that translate protocol observation frames into DimOS streams or demo artifacts without exposing simulator SDK objects. +- [x] 3.5 Add unit tests for local motor command/state round-trips and runtime client error handling. ## 4. Prelaunch orchestration and resolved plans -- [ ] 4.1 Define `BenchmarkEpisodeConfig` for backend intent, including backend, task, robot profile, control timing, observation streams, evaluator expectations, and artifact destination. -- [ ] 4.2 Define `ResolvedRuntimePlan` containing derived hardware components, runtime client config, observation stream config, evaluator config, artifact routing, and sidecar metadata. -- [ ] 4.3 Implement prelaunch orchestration that starts the sidecar, waits for health, retrieves runtime description, validates robot profiles, builds the resolved runtime plan, launches the DimOS blueprint directly, monitors both runtimes, and tears both down. -- [ ] 4.4 Add failure handling for sidecar health timeout, protocol incompatibility, robot profile mismatch, early DimOS exit, early sidecar exit, and teardown errors. -- [ ] 4.5 Add artifact writers for episode config, runtime description, resolved plan, protocol trace summary, motor trace, score output, and logs. +- [x] 4.1 Define `BenchmarkEpisodeConfig` for backend intent, including backend, task, robot profile, control timing, observation streams, evaluator expectations, and artifact destination. +- [x] 4.2 Define `ResolvedRuntimePlan` containing derived hardware components, runtime client config, observation stream config, evaluator config, artifact routing, and sidecar metadata. +- [x] 4.3 Implement prelaunch orchestration that starts the sidecar, waits for health, retrieves runtime description, validates robot profiles, builds the resolved runtime plan, launches the DimOS blueprint directly, monitors both runtimes, and tears both down. +- [x] 4.4 Add failure handling for sidecar health timeout, protocol incompatibility, robot profile mismatch, early DimOS exit, early sidecar exit, and teardown errors. +- [x] 4.5 Add artifact writers for episode config, runtime description, resolved plan, protocol trace summary, motor trace, score output, and logs. ## 5. Fake sidecar smoke demo -- [ ] 5.1 Implement a fake sidecar that speaks the runtime protocol, reports a deterministic whole-body motor surface, accepts motor actions, returns synthetic motor states, and provides score/artifact metadata. -- [ ] 5.2 Add a plain demo script for the fake sidecar that loads config, starts the fake sidecar, prelaunches DimOS, runs a scripted motor sequence for a fixed number of ticks, collects artifacts, and tears down both runtimes. -- [ ] 5.3 Add fake-sidecar demo config under an appropriate benchmark config directory. -- [ ] 5.4 Add automated or documented smoke validation showing the fake demo runs without Robosuite installed and writes expected artifacts. +- [x] 5.1 Implement a fake sidecar that speaks the runtime protocol, reports a deterministic whole-body motor surface, accepts motor actions, returns synthetic motor states, and provides score/artifact metadata. +- [x] 5.2 Add a plain demo script for the fake sidecar that loads config, starts the fake sidecar, prelaunches DimOS, runs a scripted motor sequence for a fixed number of ticks, collects artifacts, and tears down both runtimes. +- [x] 5.3 Add fake-sidecar demo config under an appropriate benchmark config directory. +- [x] 5.4 Add automated or documented smoke validation showing the fake demo runs without Robosuite installed and writes expected artifacts. ## 6. Robosuite sidecar integration -- [ ] 6.1 Implement the Robosuite sidecar server package entrypoint that owns Robosuite environment construction, reset, step, scoring metadata, and artifact export. -- [ ] 6.2 Implement Robosuite task/profile resolution for baked scenes such as `Lift` with `Panda`, controller profile, control frequency, horizon, cameras, renderer options, and seed. -- [ ] 6.3 Implement runtime-derived motor surface discovery for the Panda joint-position + gripper profile, including validation against supported command modes. -- [ ] 6.4 Implement action mapping from runtime motor position frames to Robosuite action vectors and state mapping from Robosuite observations to runtime motor state frames. -- [ ] 6.5 Implement observation export for configured Robosuite camera/state observations, including at least `agentview` metadata or frame output. -- [ ] 6.6 Add sidecar tests or simulator-gated checks for profile validation, unsupported controller failure, and Robosuite action/state mapping. +- [x] 6.1 Implement the Robosuite sidecar server package entrypoint that owns Robosuite environment construction, reset, step, scoring metadata, and artifact export. +- [x] 6.2 Implement Robosuite task/profile resolution for baked scenes such as `Lift` with `Panda`, controller profile, control frequency, horizon, cameras, renderer options, and seed. +- [x] 6.3 Implement runtime-derived motor surface discovery for the Panda joint-position + gripper profile, including validation against supported command modes. +- [x] 6.4 Implement action mapping from runtime motor position frames to Robosuite action vectors and state mapping from Robosuite observations to runtime motor state frames. +- [x] 6.5 Implement observation export for configured Robosuite camera/state observations, including at least `agentview` metadata or frame output. +- [x] 6.6 Add sidecar tests or simulator-gated checks for profile validation, unsupported controller failure, and Robosuite action/state mapping. ## 7. Robosuite Panda Lift plumbing demo -- [ ] 7.1 Add a plain Robosuite Panda Lift demo script that orchestrates the Robosuite sidecar, derives the runtime plan, launches the DimOS blueprint directly, runs a scripted motor command sequence, collects score/artifacts, and tears down both runtimes. -- [ ] 7.2 Add Robosuite Panda Lift demo config with backend `robosuite`, env `Lift`, robot `Panda`, joint-position controller profile, 100 Hz requested control, horizon, seed, and camera stream settings. -- [ ] 7.3 Verify the Robosuite demo records matching motor order/count, motor state changes from scripted commands, observation frame receipt, score metadata, protocol trace summary, and cleanup status. -- [ ] 7.4 Document the sidecar environment setup and exact command to run the Robosuite demo script. +- [x] 7.1 Add a plain Robosuite Panda Lift demo script that orchestrates the Robosuite sidecar, derives the runtime plan, launches the DimOS blueprint directly, runs a scripted motor command sequence, collects score/artifacts, and tears down both runtimes. +- [x] 7.2 Add Robosuite Panda Lift demo config with backend `robosuite`, env `Lift`, robot `Panda`, joint-position controller profile, 100 Hz requested control, horizon, seed, and camera stream settings. +- [x] 7.3 Verify the Robosuite demo records matching motor order/count, motor state changes from scripted commands, observation frame receipt, score metadata, protocol trace summary, and cleanup status. +- [x] 7.4 Document the sidecar environment setup and exact command to run the Robosuite demo script. +- [x] 7.5 Add and validate an optional Robosuite Rerun demo mode that fetches `.npy` camera payloads from the sidecar and publishes `color_image`/`camera_info` through DimOS streams for Rerun visualization. +- [x] 7.6 Isolate the Robosuite Rerun demo from unrelated LCM/Rerun streams and bound raw-image memory usage with configurable Rerun memory and publish-rate settings. ## 8. Roadmap and validation -- [ ] 8.1 Update `openspec/drafts/agentic-skill-benchmark-harness.md` as a roadmap document that references this change as the first concrete framework + Robosuite integration slice. -- [ ] 8.2 Add concise developer documentation for the architecture boundaries: remote runtime protocol, local SHM motor bridge, prelaunch orchestration, and sidecar package isolation. -- [ ] 8.3 Run relevant unit tests and the fake sidecar smoke demo. -- [ ] 8.4 Run or document the Robosuite Panda Lift plumbing demo validation in an environment with Robosuite available. +- [x] 8.1 Update `openspec/drafts/agentic-skill-benchmark-harness.md` as a roadmap document that references this change as the first concrete framework + Robosuite integration slice. +- [x] 8.2 Add concise developer documentation for the architecture boundaries: remote runtime protocol, local SHM motor bridge, prelaunch orchestration, and sidecar package isolation. +- [x] 8.3 Run relevant unit tests and the fake sidecar smoke demo. +- [x] 8.4 Run or document the Robosuite Panda Lift plumbing demo validation in an environment with Robosuite available. diff --git a/openspec/specs/benchmark-prelaunch-orchestration/spec.md b/openspec/specs/benchmark-prelaunch-orchestration/spec.md new file mode 100644 index 0000000000..0843fcb513 --- /dev/null +++ b/openspec/specs/benchmark-prelaunch-orchestration/spec.md @@ -0,0 +1,59 @@ +## Purpose + +Define how benchmark intent is resolved into concrete DimOS runtime launch material for simulator sidecar demos and benchmark episodes. + +## Requirements + +### Requirement: Benchmark episode config +The system SHALL support a benchmark episode config that declares benchmark intent, including backend selection, task identity, robot profile, control constraints, observation needs, evaluator expectations, and artifact destination before a DimOS blueprint is launched. + +#### Scenario: Robosuite task is declared as benchmark intent +- **WHEN** an episode config names backend `robosuite`, env name `Lift`, robot `Panda`, controller profile, control frequency, horizon, and seed +- **THEN** the config is treated as portable benchmark intent rather than as a precomputed DimOS hardware component + +### Requirement: Runtime prelaunch orchestration +The system SHALL provide a prelaunch orchestrator that starts the simulator sidecar first, obtains live sidecar metadata, derives concrete DimOS launch material, and then launches the DimOS blueprint. + +#### Scenario: Sidecar describes runtime before blueprint launch +- **WHEN** prelaunch starts a sidecar for an episode +- **THEN** it waits for sidecar health and runtime description before creating the resolved runtime plan used to launch DimOS + +#### Scenario: Sidecar health fails +- **WHEN** the sidecar does not become healthy within the configured timeout +- **THEN** prelaunch fails without launching the DimOS blueprint and writes a failure artifact + +### Requirement: Resolved runtime plan +The system SHALL derive a resolved runtime plan from live sidecar metadata and the episode config, including hardware components, simulator connection config, observation stream config, evaluator config, and artifact routing. + +#### Scenario: Hardware component is derived from sidecar metadata +- **WHEN** the sidecar reports an ordered whole-body motor surface for `panda` +- **THEN** the resolved runtime plan includes a matching `HardwareComponent` projection for the ControlCoordinator + +#### Scenario: Mismatched robot profile fails +- **WHEN** the episode config requests a robot profile that is incompatible with the sidecar-described motor surface +- **THEN** prelaunch fails before starting the DimOS blueprint and records the mismatch + +### Requirement: Runner owns both runtime lifetimes +The benchmark runner SHALL remain the parent owner of both the simulator sidecar process/environment and the DimOS blueprint process/environment for the duration of a demo or benchmark episode. + +#### Scenario: DimOS blueprint exits early +- **WHEN** the DimOS blueprint process exits before the episode completes +- **THEN** the runner tears down the sidecar and records failure attribution and logs from both runtimes + +#### Scenario: Sidecar exits early +- **WHEN** the sidecar exits before the episode completes +- **THEN** the runner tears down the DimOS blueprint and records sidecar failure details + +### Requirement: Local SHM is not a remote sidecar protocol +The system SHALL restrict SHM usage to local DimOS motor control plumbing between the runtime client module and ControlCoordinator-facing WholeBodyAdapter. + +#### Scenario: Sidecar runs remotely +- **WHEN** the sidecar endpoint is configured for another host +- **THEN** DimOS communicates with it through the runtime network protocol and does not require remote SHM access + +### Requirement: Plain script entrypoints +The system SHALL provide plain script entrypoints for v1 demos and MUST NOT require a new `dimos` CLI command for this change. + +#### Scenario: Demo is launched from script +- **WHEN** a developer runs the fake sidecar or Robosuite demo script +- **THEN** the script performs prelaunch orchestration and calls or builds the relevant DimOS blueprint directly diff --git a/openspec/specs/robosuite-runtime-sidecar/spec.md b/openspec/specs/robosuite-runtime-sidecar/spec.md new file mode 100644 index 0000000000..df89cf24aa --- /dev/null +++ b/openspec/specs/robosuite-runtime-sidecar/spec.md @@ -0,0 +1,51 @@ +## Purpose + +Define the Robosuite runtime sidecar boundary for baked Robosuite task execution, motor-surface mapping, observation export, and score/artifact reporting. + +## Requirements + +### Requirement: Robosuite sidecar package +The system SHALL provide a first-class Robosuite sidecar package in the monorepo that depends on the runtime protocol package and Robosuite-specific dependencies without depending on the main DimOS package. + +#### Scenario: Robosuite sidecar installs in isolated environment +- **WHEN** a developer installs the Robosuite sidecar package in a Robosuite-compatible environment +- **THEN** the sidecar can start and import runtime protocol models without installing the main DimOS package + +### Requirement: Baked Robosuite task instantiation +The Robosuite sidecar SHALL instantiate baked Robosuite tasks from episode config fields such as env name, robot name, controller profile, control frequency, horizon, renderer options, camera options, and seed. + +#### Scenario: Panda Lift task starts +- **WHEN** the episode config requests `env_name: Lift` and `robots: Panda` +- **THEN** the sidecar creates the corresponding Robosuite environment and exposes its runtime description + +### Requirement: Runtime-derived motor surface +The Robosuite sidecar SHALL derive robot motor surface metadata from the live Robosuite environment and controller setup rather than requiring every benchmark config to manually enumerate Robosuite action indices. + +#### Scenario: Panda motor order is described +- **WHEN** the sidecar creates a Panda Lift environment +- **THEN** it reports a stable ordered motor surface suitable for DimOS whole-body motor control + +#### Scenario: Unsupported controller profile fails +- **WHEN** the selected Robosuite controller profile cannot be mapped to a supported DimOS motor command mode +- **THEN** the sidecar rejects the episode setup with a protocol error that identifies the unsupported profile + +### Requirement: Robosuite step ownership +The Robosuite sidecar SHALL own backend-native `env.reset()` and `env.step(action)` calls and SHALL translate between runtime protocol action/state frames and Robosuite action/observation structures. + +#### Scenario: Motor position step advances Robosuite +- **WHEN** DimOS sends a motor position action frame for the described Panda motor surface +- **THEN** the sidecar maps it to the Robosuite action vector, steps the environment, and returns motor state, reward, done, success if available, and observation metadata + +### Requirement: Observation export +The Robosuite sidecar SHALL expose configured Robosuite camera and state observations through runtime protocol observation frames that DimOS can publish as observation streams. + +#### Scenario: Agentview camera is available +- **WHEN** the episode config enables the `agentview` camera +- **THEN** step responses include observation frames with `.npy` payload references that allow DimOS to fetch and publish the camera output as a stream + +### Requirement: Score and artifact export +The Robosuite sidecar SHALL provide score and artifact outputs for each episode, including reward/done/success metadata, backend timing, and sidecar logs or trace summaries. + +#### Scenario: Score is collected after demo +- **WHEN** the demo completes or times out +- **THEN** the runner can request score output from the sidecar and write it with the episode artifacts diff --git a/openspec/specs/runtime-sidecar-protocol/spec.md b/openspec/specs/runtime-sidecar-protocol/spec.md new file mode 100644 index 0000000000..a1666206c9 --- /dev/null +++ b/openspec/specs/runtime-sidecar-protocol/spec.md @@ -0,0 +1,56 @@ +## Purpose + +Define the lightweight backend-neutral protocol contract shared by DimOS runtime clients and simulator sidecars. + +## Requirements + +### Requirement: Shared runtime protocol package +The system SHALL provide a lightweight installable runtime protocol package that can be used by DimOS and simulator sidecars without installing the main DimOS package or any simulator backend SDK. + +#### Scenario: Sidecar installs protocol without DimOS +- **WHEN** a Robosuite sidecar environment installs the runtime protocol package +- **THEN** it can import the protocol models and codecs without importing `dimos`, Robosuite-incompatible DimOS dependencies, or any DimOS hardware adapter modules + +#### Scenario: DimOS imports the same protocol package +- **WHEN** the DimOS runtime client imports protocol models +- **THEN** it uses the same package and protocol version as the sidecar compatibility handshake + +### Requirement: Protocol model validation +The protocol package SHALL define Pydantic models for runtime description, episode reset, step requests, step responses, robot motor surfaces, motor action frames, motor state frames, observation frames, scores, artifacts, and errors. + +#### Scenario: Invalid step request is rejected +- **WHEN** a step request omits required episode identity, tick identity, or action payload fields +- **THEN** protocol validation rejects the message before backend-specific step logic runs + +#### Scenario: Runtime description reports motor surface +- **WHEN** a sidecar describes a robot runtime +- **THEN** the response includes robot id, surface type, ordered motors, supported command modes, and available state fields + +### Requirement: Protocol compatibility handshake +The runtime protocol SHALL include protocol version and capability metadata in the sidecar handshake so DimOS can fail fast on incompatible protocol versions or unsupported capabilities. + +#### Scenario: Compatible sidecar connects +- **WHEN** DimOS connects to a sidecar using a compatible protocol version +- **THEN** the runtime client accepts the sidecar description and records the protocol version in artifacts + +#### Scenario: Incompatible sidecar connects +- **WHEN** DimOS connects to a sidecar using an incompatible protocol version +- **THEN** prelaunch fails before launching the DimOS blueprint and records the incompatibility reason + +### Requirement: Binary-friendly observation transport +The runtime protocol SHALL support image, depth, segmentation, and object/state observations without requiring large image tensors to be encoded as nested JSON lists. + +#### Scenario: Image observation uses reference or binary payload +- **WHEN** a sidecar returns an RGB image observation +- **THEN** the observation frame includes stream name, kind, encoding, shape, dtype, and either a binary payload reference or a supported binary payload representation + +#### Scenario: Referenced array payload is fetched separately +- **WHEN** an observation frame reports a raw array payload reference +- **THEN** the DimOS runtime client can fetch the referenced `.npy` bytes without embedding image arrays in the JSON step response + +### Requirement: Backend-neutral protocol types +Runtime protocol models MUST NOT expose Robosuite, LIBERO-PRO, OmniGibson, DimOS hardware adapter, or simulator object types in public fields. + +#### Scenario: Robosuite observation is translated +- **WHEN** Robosuite produces an `OrderedDict` observation +- **THEN** the Robosuite sidecar translates it into runtime protocol observation and motor state frames before sending it to DimOS diff --git a/openspec/specs/scripted-runtime-demos/spec.md b/openspec/specs/scripted-runtime-demos/spec.md new file mode 100644 index 0000000000..48027b7bc3 --- /dev/null +++ b/openspec/specs/scripted-runtime-demos/spec.md @@ -0,0 +1,53 @@ +## Purpose + +Define script-based runtime sidecar demos that validate protocol, motor-control, observation, visualization, artifact, and teardown plumbing without adding a new DimOS CLI command or requiring agent task success. + +## Requirements + +### Requirement: Fake sidecar smoke demo +The system SHALL include a script-based fake sidecar smoke demo that validates protocol handshake, prelaunch orchestration, resolved runtime plan generation, local motor bridge behavior, ControlCoordinator integration, and artifact output without requiring Robosuite. + +#### Scenario: Fake demo completes in normal DimOS environment +- **WHEN** a developer runs the fake sidecar demo script in the normal DimOS development environment +- **THEN** the demo completes a configured number of ticks and writes episode config, resolved plan, protocol trace summary, motor trace, score output, and logs + +#### Scenario: Fake demo exercises motor command/state flow +- **WHEN** the fake demo runs a scripted motor command sequence +- **THEN** commands flow from the ControlCoordinator-facing surface through the local bridge and protocol client, and motor states flow back into the DimOS side + +### Requirement: Robosuite Panda Lift plumbing demo +The system SHALL include a script-based Robosuite Panda Lift plumbing demo that validates the real Robosuite sidecar, runtime description, derived motor mapping, network protocol, local motor bridge, observation stream export, score collection, and artifact output. + +#### Scenario: Robosuite demo starts sidecar and DimOS blueprint +- **WHEN** a developer runs the Robosuite Panda Lift demo script with a compatible Robosuite sidecar environment +- **THEN** the script starts the sidecar, derives the resolved runtime plan from live sidecar metadata, starts the DimOS blueprint, runs the scripted sequence, collects artifacts, and tears down both runtimes + +#### Scenario: Robosuite joint state changes from scripted command +- **WHEN** the Robosuite demo sends a scripted motor position command sequence +- **THEN** the returned Robosuite-derived motor state changes consistently with the command sequence and is recorded in the motor trace + +#### Scenario: Robosuite observation stream is exported +- **WHEN** the Robosuite demo enables a camera observation stream +- **THEN** DimOS-side artifacts or logs show that at least one observation frame was received from the sidecar and published or recorded by the runtime client + +#### Scenario: Robosuite camera appears in Rerun through DimOS streams +- **WHEN** a developer runs the Robosuite demo with Rerun stream visualization enabled +- **THEN** the demo fetches referenced `.npy` camera payloads, applies the declared image convention, publishes `color_image` and `camera_info` through DimOS streams, and Rerun displays the camera image through its normal stream bridge rather than through direct Rerun SDK logging at the runtime boundary + +#### Scenario: Rerun demo stream is isolated and bounded +- **WHEN** a developer runs the Robosuite demo with Rerun stream visualization enabled repeatedly or alongside other DimOS publishers +- **THEN** the demo uses isolated Rerun and local DimOS transport settings for its visualization path and applies a bounded Rerun memory limit so old recordings or unrelated camera topics do not mix with the demo stream + +### Requirement: No agent success requirement +The scripted demos SHALL verify runtime plumbing and MUST NOT require an LLM, MCP skill policy, or successful task completion by an agent. + +#### Scenario: Robosuite task is not solved +- **WHEN** the scripted Robosuite demo does not lift the object successfully +- **THEN** the demo can still pass if protocol, motor flow, observation flow, score collection, and teardown satisfy the demo acceptance checks + +### Requirement: No DimOS CLI integration +The scripted demos SHALL be launched through plain scripts and MUST NOT require a new `dimos benchmark` command. + +#### Scenario: Developer runs demo script directly +- **WHEN** a developer invokes the demo script with a config path +- **THEN** the script performs orchestration directly rather than delegating to a new DimOS CLI subcommand diff --git a/packages/dimos-fake-runtime-sidecar/pyproject.toml b/packages/dimos-fake-runtime-sidecar/pyproject.toml new file mode 100644 index 0000000000..c10feb0be3 --- /dev/null +++ b/packages/dimos-fake-runtime-sidecar/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "dimos-fake-runtime-sidecar" +version = "0.1.0" +description = "Fake DimOS runtime sidecar for benchmark smoke demos" +requires-python = ">=3.10" +dependencies = [ + "dimos-runtime-protocol", +] + +[project.scripts] +dimos-fake-runtime-sidecar = "dimos_fake_runtime_sidecar.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/dimos_fake_runtime_sidecar"] diff --git a/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/__init__.py b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/__init__.py new file mode 100644 index 0000000000..3e883e098f --- /dev/null +++ b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/__init__.py @@ -0,0 +1 @@ +"""Fake benchmark runtime sidecar.""" diff --git a/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/server.py b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/server.py new file mode 100644 index 0000000000..072d007044 --- /dev/null +++ b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/server.py @@ -0,0 +1,204 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""HTTP fake sidecar that speaks the DimOS runtime protocol.""" + +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import time + +from dimos_runtime_protocol.models import ( + CommandMode, + EpisodeResetRequest, + EpisodeResetResponse, + HealthResponse, + MotorDescription, + MotorStateFrame, + RobotMotorSurface, + RuntimeDescription, + ScoreOutput, + StepRequest, + StepResponse, +) + + +def _default_names(robot_id: str, dof: int) -> list[str]: + return [f"{robot_id}/joint{i + 1}" for i in range(dof)] + + +class FakeRuntimeState: + """Deterministic state machine for the fake runtime sidecar.""" + + def __init__(self, *, robot_id: str = "fakebot", dof: int = 3, step_hz: int = 100) -> None: + self.robot_id = robot_id + self.names = _default_names(robot_id, dof) + self.step_hz = step_hz + self.episode_id = "unreset" + self.q = [0.0] * dof + self.dq = [0.0] * dof + self.tau = [0.0] * dof + self.sequence = 0 + + def describe(self) -> RuntimeDescription: + motors = [MotorDescription(name=name, index=i) for i, name in enumerate(self.names)] + surface = RobotMotorSurface( + robot_id=self.robot_id, + motors=motors, + supported_command_modes=[CommandMode.POSITION], + ) + return RuntimeDescription( + runtime_id="fake-runtime", + backend="fake", + capabilities=["motor.position", "score.simple"], + robot_surfaces=[surface], + control_step_hz=self.step_hz, + observation_streams=["fake_state"], + metadata={"dof": len(self.names)}, + ) + + def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: + self.episode_id = request.episode_id + self.q = [0.0] * len(self.names) + self.dq = [0.0] * len(self.names) + self.tau = [0.0] * len(self.names) + self.sequence = 0 + return EpisodeResetResponse( + episode_id=request.episode_id, + runtime_description=self.describe(), + observations=[], + ) + + def step(self, request: StepRequest) -> StepResponse: + previous = list(self.q) + targets = request.action.q + if request.action.names != self.names: + raise ValueError(f"motor names mismatch: expected {self.names}, got {request.action.names}") + if len(targets) != len(self.names): + raise ValueError(f"target length mismatch: expected {len(self.names)}, got {len(targets)}") + alpha = 0.35 + self.q = [old + alpha * (target - old) for old, target in zip(self.q, targets, strict=True)] + self.dq = [(new - old) * self.step_hz for old, new in zip(previous, self.q, strict=True)] + self.sequence += 1 + motor_state = MotorStateFrame( + robot_id=self.robot_id, + names=self.names, + q=self.q, + dq=self.dq, + tau=self.tau, + sequence=self.sequence, + timestamp_s=time.time(), + ) + return StepResponse( + episode_id=request.episode_id, + tick_id=request.tick_id, + motor_state=motor_state, + observations=[], + reward=float(sum(abs(v) for v in self.q)), + done=False, + success=False, + info={"backend_sequence": self.sequence}, + ) + + def score(self) -> ScoreOutput: + moved = any(abs(v) > 1e-6 for v in self.q) + return ScoreOutput( + episode_id=self.episode_id, + success=moved, + score=1.0 if moved else 0.0, + reason="fake runtime observed motor movement" if moved else "no movement observed", + metrics={"sequence": self.sequence}, + ) + + +class FakeRuntimeHandler(BaseHTTPRequestHandler): + """Request handler bound to one FakeRuntimeState instance.""" + + state: FakeRuntimeState + + def _write_json(self, status: int, payload: dict[str, object]) -> None: + data = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _read_json(self) -> dict[str, object]: + length = int(self.headers.get("Content-Length", "0")) + if length == 0: + return {} + data = self.rfile.read(length) + value = json.loads(data.decode("utf-8")) + if not isinstance(value, dict): + raise ValueError("expected JSON object") + return value + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/health": + self._write_json(200, HealthResponse(ok=True, runtime_id="fake-runtime").model_dump()) + elif self.path == "/describe": + self._write_json(200, self.state.describe().model_dump()) + elif self.path == "/score": + self._write_json(200, self.state.score().model_dump()) + else: + self._write_json(404, {"error": f"unknown path {self.path}"}) + + def do_POST(self) -> None: # noqa: N802 + try: + body = self._read_json() + if self.path == "/reset": + request = EpisodeResetRequest.model_validate(body) + self._write_json(200, self.state.reset(request).model_dump()) + elif self.path == "/step": + request = StepRequest.model_validate(body) + self._write_json(200, self.state.step(request).model_dump()) + else: + self._write_json(404, {"error": f"unknown path {self.path}"}) + except Exception as exc: + self._write_json(400, {"error": str(exc)}) + + def log_message(self, format: str, *args: object) -> None: + return + + +def make_server(host: str, port: int, *, robot_id: str = "fakebot", dof: int = 3) -> ThreadingHTTPServer: + state = FakeRuntimeState(robot_id=robot_id, dof=dof) + + class BoundHandler(FakeRuntimeHandler): + pass + + BoundHandler.state = state + return ThreadingHTTPServer((host, port), BoundHandler) + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="Run fake DimOS runtime sidecar") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--robot-id", default="fakebot") + parser.add_argument("--dof", type=int, default=3) + args = parser.parse_args() + server = make_server(args.host, args.port, robot_id=args.robot_id, dof=args.dof) + try: + server.serve_forever() + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/packages/dimos-robosuite-sidecar/pyproject.toml b/packages/dimos-robosuite-sidecar/pyproject.toml new file mode 100644 index 0000000000..bb0abe0f83 --- /dev/null +++ b/packages/dimos-robosuite-sidecar/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "dimos-robosuite-sidecar" +version = "0.1.0" +description = "Robosuite runtime sidecar for DimOS benchmark plumbing demos" +requires-python = ">=3.10" +dependencies = [ + "dimos-runtime-protocol", +] + +[project.optional-dependencies] +robosuite = ["robosuite>=1.5"] + +[project.scripts] +dimos-robosuite-sidecar = "dimos_robosuite_sidecar.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/dimos_robosuite_sidecar"] diff --git a/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/__init__.py b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/__init__.py new file mode 100644 index 0000000000..c320b0b89d --- /dev/null +++ b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/__init__.py @@ -0,0 +1 @@ +"""Robosuite runtime sidecar package.""" diff --git a/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/server.py b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/server.py new file mode 100644 index 0000000000..9cc23c77bf --- /dev/null +++ b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/server.py @@ -0,0 +1,557 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""HTTP sidecar for a narrow Robosuite Panda Lift runtime profile.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, HTTPServer +from io import BytesIO +import json +from pathlib import Path +import time +from types import ModuleType +from typing import ClassVar, Mapping, Sequence, cast +from urllib.parse import unquote, urlparse + +from dimos_runtime_protocol import ( + CommandMode, + EpisodeResetRequest, + EpisodeResetResponse, + HealthResponse, + MotorDescription, + MotorStateFrame, + ObservationFrame, + ObservationKind, + ProtocolVersion, + RobotMotorSurface, + RuntimeDescription, + ScoreOutput, + StepRequest, + StepResponse, +) + + +def require_robosuite() -> ModuleType: + """Import Robosuite only inside the sidecar runtime path.""" + + try: + import robosuite + except ImportError as exc: + raise RuntimeError( + "Robosuite is required for dimos-robosuite-sidecar. " + "Install the sidecar in a Robosuite-compatible environment." + ) from exc + return robosuite + + +@dataclass(frozen=True) +class RobosuiteRuntimeConfig: + host: str + port: int + env_name: str + robot_id: str + robot_model: str + controller: str + control_freq: int + horizon: int + camera_name: str + seed: int | None + visualize: bool = False + image_dump_dir: str | None = None + image_dump_every: int = 0 + + +class RobosuiteRuntimeState: + """Owns one Robosuite env and maps it to the DimOS runtime protocol.""" + + def __init__(self, config: RobosuiteRuntimeConfig) -> None: + self.config = config + self._robosuite = require_robosuite() + self._env = self._make_env() + self._episode_id = "" + self._sequence = 0 + self._last_obs: Mapping[str, object] = {} + self._last_reward = 0.0 + self._last_done = False + self._last_success: bool | None = None + self._payloads: dict[str, bytes] = {} + self._action_low, self._action_high = self._action_bounds() + self.motor_names = self._motor_names() + if len(self._action_low) != len(self.motor_names): + raise RuntimeError( + "Panda Lift v1 profile expects action dimension to match motor surface: " + f"action_dim={len(self._action_low)} motors={len(self.motor_names)}" + ) + + def _make_env(self) -> object: + robosuite = self._robosuite + controllers = getattr(robosuite, "controllers") + controller_config = self._controller_config(controllers) + make_env = getattr(robosuite, "make") + return make_env( + env_name=self.config.env_name, + robots=self.config.robot_model, + controller_configs=controller_config, + has_renderer=self.config.visualize, + has_offscreen_renderer=True, + use_camera_obs=True, + camera_names=[self.config.camera_name], + # Use free-camera mode for the on-screen viewer so the user can + # rotate / pan / zoom interactively. The named camera remains enabled + # for observation frames (`agentview` by default). + render_camera=None if self.config.visualize else self.config.camera_name, + camera_depths=False, + control_freq=self.config.control_freq, + horizon=self.config.horizon, + seed=self.config.seed, + ) + + def _controller_config(self, controllers: object) -> object: + load_composite = getattr(controllers, "load_composite_controller_config") + controller_config = load_composite(controller="BASIC") + if self.config.controller in {"JOINT_POSITION", "PANDA_JOINT_POSITION"}: + load_part = getattr(controllers, "load_part_controller_config") + right_config = load_part(default_controller="JOINT_POSITION") + right_config["gripper"] = {"type": "GRIP"} + controller_config["body_parts"]["right"] = right_config + return controller_config + if self.config.controller == "BASIC": + return controller_config + raise RuntimeError(f"unsupported Robosuite controller profile: {self.config.controller}") + + def _action_bounds(self) -> tuple[list[float], list[float]]: + low, high = self._env.action_spec # type: ignore[attr-defined] + return _float_list(low), _float_list(high) + + def _motor_names(self) -> list[str]: + # Narrow v1 profile: Panda arm joints plus scalar gripper command. + if self.config.robot_model != "Panda": + raise RuntimeError(f"unsupported robot model for v1 Robosuite sidecar: {self.config.robot_model}") + return [f"{self.config.robot_id}/joint{i + 1}" for i in range(7)] + [ + f"{self.config.robot_id}/gripper" + ] + + def describe(self) -> RuntimeDescription: + return RuntimeDescription( + runtime_id="robosuite-panda-lift", + backend="robosuite", + protocol=ProtocolVersion(), + capabilities=["sync-http", "whole-body-motor-position", "robosuite-panda-lift"], + robot_surfaces=[ + RobotMotorSurface( + robot_id=self.config.robot_id, + motors=[ + MotorDescription(name=name, index=index) + for index, name in enumerate(self.motor_names) + ], + supported_command_modes=[CommandMode.POSITION], + ) + ], + control_step_hz=self.config.control_freq, + observation_streams=[self.config.camera_name, "robot_state"], + metadata={ + "env_name": self.config.env_name, + "robot_model": self.config.robot_model, + "controller": self.config.controller, + "horizon": self.config.horizon, + "visualize": self.config.visualize, + "action_low": self._action_low, + "action_high": self._action_high, + }, + ) + + def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: + self._episode_id = request.episode_id + self._sequence = 0 + self._last_obs = cast(Mapping[str, object], self._env.reset()) # type: ignore[attr-defined] + self._last_reward = 0.0 + self._last_done = False + self._last_success = None + # Store protocol payloads before updating the interactive viewer. In + # visual mode Robosuite/MuJoCo viewer updates can touch render buffers + # that observation arrays may reference, causing alternating/stale + # colors in the exported camera payloads. + observations = self._observations(0) + self._render_if_enabled() + return EpisodeResetResponse( + episode_id=request.episode_id, + runtime_description=self.describe(), + observations=observations, + ) + + def step(self, request: StepRequest) -> StepResponse: + if request.action.robot_id != self.config.robot_id: + raise ValueError(f"unexpected robot id {request.action.robot_id!r}") + if request.action.names != self.motor_names: + raise ValueError("action motor names do not match runtime surface") + if request.action.mode != CommandMode.POSITION: + raise ValueError(f"unsupported command mode {request.action.mode}") + action = self._action_from_request(request) + obs, reward, done, info = self._env.step(action) # type: ignore[attr-defined] + self._sequence += 1 + self._last_obs = cast(Mapping[str, object], obs) + self._last_reward = float(reward) + self._last_done = bool(done) + self._last_success = _success_from_info(info) + motor_state = self._motor_state(request.tick_id) + # Store protocol payloads before updating the interactive viewer; see + # reset() for the render-buffer ordering rationale. + observations = self._observations(request.tick_id) + self._render_if_enabled() + return StepResponse( + episode_id=request.episode_id, + tick_id=request.tick_id, + motor_state=motor_state, + observations=observations, + reward=self._last_reward, + done=self._last_done, + success=self._last_success, + info={"robosuite_info_keys": sorted(str(key) for key in info.keys())}, + ) + + def _render_if_enabled(self) -> None: + if not self.config.visualize: + return + render = getattr(self._env, "render") + render() + + def score(self) -> ScoreOutput: + success = bool(self._last_success) if self._last_success is not None else False + return ScoreOutput( + episode_id=self._episode_id or "uninitialized", + success=success, + score=1.0 if success else 0.0, + reason="robosuite success flag" if success else "success not observed", + metrics={"reward": self._last_reward, "done": self._last_done, "sequence": self._sequence}, + ) + + def _action_from_request(self, request: StepRequest) -> object: + import numpy as np + + if len(request.action.q) != len(self.motor_names): + raise ValueError(f"expected {len(self.motor_names)} q targets, got {len(request.action.q)}") + values = np.array(request.action.q, dtype=np.float64) + low = np.array(self._action_low, dtype=np.float64) + high = np.array(self._action_high, dtype=np.float64) + return np.clip(values, low, high) + + def _motor_state(self, tick_id: int) -> MotorStateFrame: + joint_q = _float_list(self._last_obs.get("robot0_joint_pos", [])) + joint_dq = _float_list(self._last_obs.get("robot0_joint_vel", [])) + gripper_q = _float_list(self._last_obs.get("robot0_gripper_qpos", [])) + gripper_dq = _float_list(self._last_obs.get("robot0_gripper_qvel", [])) + q = (joint_q[:7] + [_mean_or_zero(gripper_q)])[: len(self.motor_names)] + dq = (joint_dq[:7] + [_mean_or_zero(gripper_dq)])[: len(self.motor_names)] + if len(q) != len(self.motor_names): + q = q + [0.0] * (len(self.motor_names) - len(q)) + if len(dq) != len(self.motor_names): + dq = dq + [0.0] * (len(self.motor_names) - len(dq)) + return MotorStateFrame( + robot_id=self.config.robot_id, + names=self.motor_names, + q=q, + dq=dq, + tau=[0.0] * len(self.motor_names), + sequence=self._sequence, + timestamp_s=time.time(), + ) + + def _observations(self, tick_id: int) -> list[ObservationFrame]: + frames = [ + ObservationFrame( + stream="robot_state", + kind=ObservationKind.STATE, + inline_text=f"tick={tick_id} reward={self._last_reward}", + metadata={"sequence": self._sequence}, + ) + ] + image = self._camera_image() + if image is not None: + payload_id, payload_bytes = self._store_payload(self.config.camera_name, tick_id, image) + frames.append( + ObservationFrame( + stream=self.config.camera_name, + kind=ObservationKind.IMAGE, + encoding="npy", + shape=_shape_list(image), + dtype=str(getattr(image, "dtype", "")), + data_ref=f"/payloads/{payload_id}", + metadata={ + "sequence": self._sequence, + "camera_name": self.config.camera_name, + "camera_mount": _camera_mount(self.config.camera_name), + "camera_source": "robosuite_observation", + "image_convention": self._image_convention(), + "fov_y_deg": self._camera_fov_deg(), + **_array_metadata(image, payload_bytes), + }, + ) + ) + return frames + + def _camera_image(self) -> object | None: + """Return a copied Robosuite camera observation. + + Robosuite's interactive viewer and direct `sim.render(...)` paths can use + different render contexts. For the protocol payload, use the observation + produced by `env.reset()` / `env.step()` and copy it immediately so later + viewer updates cannot mutate the exported array. + """ + + cached_image = self._last_obs.get(f"{self.config.camera_name}_image") + if cached_image is None: + return None + return _array_copy(cached_image) + + def _store_payload(self, stream: str, tick_id: int, value: object) -> tuple[str, bytes]: + payload_id = f"{stream}-{tick_id:06d}-{self._sequence:06d}.npy" + payload_bytes = _npy_bytes(value) + self._payloads[payload_id] = payload_bytes + self._dump_source_image_if_enabled(payload_id.removesuffix(".npy"), value) + if len(self._payloads) > 32: + for key in list(self._payloads)[: len(self._payloads) - 32]: + del self._payloads[key] + return payload_id, payload_bytes + + def _dump_source_image_if_enabled(self, stem: str, value: object) -> None: + if self.config.image_dump_dir is None or self.config.image_dump_every <= 0: + return + if self._sequence % self.config.image_dump_every != 0: + return + output_dir = Path(self.config.image_dump_dir) + _write_rgb_jpeg(output_dir / f"{stem}_server_raw.jpg", value) + if self._image_convention() == "opengl": + import numpy as np + + _write_rgb_jpeg(output_dir / f"{stem}_server_display.jpg", np.flipud(np.asarray(value))) + else: + _write_rgb_jpeg(output_dir / f"{stem}_server_display.jpg", value) + + def payload_bytes(self, payload_id: str) -> bytes: + try: + return self._payloads[payload_id] + except KeyError as exc: + raise FileNotFoundError(f"unknown payload id {payload_id!r}") from exc + + def _camera_fov_deg(self) -> float: + try: + sim = getattr(self._env, "sim") + model = getattr(sim, "model") + camera_id = model.camera_name2id(self.config.camera_name) + return float(model.cam_fovy[camera_id]) + except Exception: + return 45.0 + + def _image_convention(self) -> str: + macros = getattr(self._robosuite, "macros", None) + return str(getattr(macros, "IMAGE_CONVENTION", "opengl")) + + +class RobosuiteRuntimeHandler(BaseHTTPRequestHandler): + state: ClassVar[RobosuiteRuntimeState] + + def do_GET(self) -> None: + if self.path == "/health": + self._write_model( + HealthResponse(ok=True, runtime_id="robosuite-panda-lift", protocol=ProtocolVersion()) + ) + elif self.path == "/describe": + self._write_model(self.state.describe()) + elif self.path == "/score": + self._write_model(self.state.score()) + elif self.path.startswith("/payloads/"): + payload_id = unquote(urlparse(self.path).path.removeprefix("/payloads/")) + try: + self._write_bytes(self.state.payload_bytes(payload_id), content_type="application/x-npy") + except FileNotFoundError: + self.send_error(HTTPStatus.NOT_FOUND) + else: + self.send_error(HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: + try: + body = self.rfile.read(int(self.headers.get("content-length", "0"))).decode("utf-8") + payload = json.loads(body) if body else {} + if self.path == "/reset": + self._write_model(self.state.reset(EpisodeResetRequest.model_validate(payload))) + elif self.path == "/step": + self._write_model(self.state.step(StepRequest.model_validate(payload))) + else: + self.send_error(HTTPStatus.NOT_FOUND) + except Exception as exc: + self.send_response(HTTPStatus.BAD_REQUEST) + self.send_header("content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"code": "bad_request", "message": str(exc)}).encode()) + + def log_message(self, format: str, *args: object) -> None: + return + + def _write_model(self, model: object) -> None: + model_dump = getattr(model, "model_dump", None) + payload = model_dump(mode="json") if callable(model_dump) else model + self.send_response(HTTPStatus.OK) + self.send_header("content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(payload).encode("utf-8")) + + def _write_bytes(self, payload: bytes, *, content_type: str) -> None: + self.send_response(HTTPStatus.OK) + self.send_header("content-type", content_type) + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def make_server(config: RobosuiteRuntimeConfig) -> HTTPServer: + RobosuiteRuntimeHandler.state = RobosuiteRuntimeState(config) + # Robosuite/MuJoCo render contexts are thread-sensitive. Keep all env reset, + # step, offscreen camera, and interactive viewer calls on the server thread + # instead of using ThreadingHTTPServer worker threads. + return HTTPServer((config.host, config.port), RobosuiteRuntimeHandler) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8766) + parser.add_argument("--env-name", default="Lift") + parser.add_argument("--robot-id", default="panda") + parser.add_argument("--robot-model", default="Panda") + parser.add_argument("--controller", default="JOINT_POSITION") + parser.add_argument("--control-freq", type=int, default=100) + parser.add_argument("--horizon", type=int, default=200) + parser.add_argument("--camera-name", default="agentview") + parser.add_argument("--seed", type=int, default=None) + parser.add_argument("--visualize", action="store_true") + parser.add_argument("--image-dump-dir", default=None) + parser.add_argument("--image-dump-every", type=int, default=0) + args = parser.parse_args() + config = RobosuiteRuntimeConfig( + host=args.host, + port=args.port, + env_name=args.env_name, + robot_id=args.robot_id, + robot_model=args.robot_model, + controller=args.controller, + control_freq=args.control_freq, + horizon=args.horizon, + camera_name=args.camera_name, + seed=args.seed, + visualize=args.visualize, + image_dump_dir=args.image_dump_dir, + image_dump_every=args.image_dump_every, + ) + server = make_server(config) + try: + server.serve_forever() + finally: + server.server_close() + + +def _float_list(value: object) -> list[float]: + tolist = getattr(value, "tolist", None) + if callable(tolist): + value = tolist() + if isinstance(value, Sequence) and not isinstance(value, str | bytes): + return [float(item) for item in value] + return [] + + +def _mean_or_zero(values: Sequence[float]) -> float: + if not values: + return 0.0 + return float(sum(values) / len(values)) + + +def _shape_list(value: object) -> list[int]: + shape = getattr(value, "shape", None) + if isinstance(shape, Sequence): + return [int(item) for item in shape] + return [] + + +def _npy_bytes(value: object) -> bytes: + import numpy as np + + buffer = BytesIO() + np.save(buffer, np.asarray(value), allow_pickle=False) + return buffer.getvalue() + + +def _array_copy(value: object) -> object: + import numpy as np + + return np.ascontiguousarray(np.asarray(value)).copy() + + +def _write_rgb_jpeg(path: Path, rgb: object) -> None: + import cv2 + import numpy as np + + array = np.asarray(rgb) + if array.ndim != 3 or array.shape[2] < 3: + raise ValueError(f"expected HxWx3 RGB image, got shape {array.shape}") + path.parent.mkdir(parents=True, exist_ok=True) + bgr = cv2.cvtColor(array[:, :, :3], cv2.COLOR_RGB2BGR) + if not cv2.imwrite(str(path), bgr): + raise RuntimeError(f"failed to write JPEG {path}") + + +def _array_metadata(value: object, payload: bytes) -> dict[str, object]: + import numpy as np + + array = np.ascontiguousarray(np.asarray(value)) + flat = array.reshape(-1, array.shape[-1]) if array.ndim == 3 else array.reshape(-1, 1) + return { + "array_sha256": hashlib.sha256(array.tobytes()).hexdigest(), + "payload_sha256": hashlib.sha256(payload).hexdigest(), + "min": float(array.min()) if array.size else 0.0, + "max": float(array.max()) if array.size else 0.0, + "mean": float(array.mean()) if array.size else 0.0, + "top_left": [int(item) for item in flat[0].tolist()] if flat.size else [], + "center": [int(item) for item in array[array.shape[0] // 2, array.shape[1] // 2].tolist()] + if array.ndim == 3 and array.size + else [], + "bottom_left": [int(item) for item in array[-1, 0].tolist()] + if array.ndim == 3 and array.size + else [], + } + + +def _success_from_info(info: Mapping[object, object]) -> bool | None: + for key in ("success", "is_success", "task_success"): + value = info.get(key) + if isinstance(value, bool): + return value + if isinstance(value, int | float): + return bool(value) + return None + + +def _camera_mount(camera_name: str) -> str: + if "eye_in_hand" in camera_name: + return "wrist" + if "robotview" in camera_name: + return "robot_external" + return "scene" + + +if __name__ == "__main__": + main() diff --git a/packages/dimos-runtime-protocol/pyproject.toml b/packages/dimos-runtime-protocol/pyproject.toml new file mode 100644 index 0000000000..b8c4d50232 --- /dev/null +++ b/packages/dimos-runtime-protocol/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "dimos-runtime-protocol" +version = "0.1.0" +description = "Lightweight DimOS benchmark runtime protocol models and codecs" +requires-python = ">=3.10" +dependencies = [ + "pydantic>=2.0", + "typing-extensions>=4.12", +] + +[project.optional-dependencies] +msgpack = ["msgpack>=1.1"] +test = ["pytest>=8"] + +[tool.hatch.build.targets.wheel] +packages = ["src/dimos_runtime_protocol"] diff --git a/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/__init__.py b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/__init__.py new file mode 100644 index 0000000000..87f6f7e393 --- /dev/null +++ b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/__init__.py @@ -0,0 +1,46 @@ +"""DimOS benchmark runtime protocol package.""" + +from dimos_runtime_protocol.compat import CompatibilityResult, check_compatible +from dimos_runtime_protocol.models import ( + ArtifactOutput, + CommandMode, + EpisodeResetRequest, + EpisodeResetResponse, + ErrorResponse, + HealthResponse, + MotorActionFrame, + MotorDescription, + MotorStateFrame, + ObservationFrame, + ObservationKind, + ProtocolVersion, + RobotMotorSurface, + RuntimeDescription, + ScoreOutput, + StepRequest, + StepResponse, +) +from dimos_runtime_protocol.version import PROTOCOL_VERSION + +__all__ = [ + "PROTOCOL_VERSION", + "ArtifactOutput", + "CommandMode", + "CompatibilityResult", + "EpisodeResetRequest", + "EpisodeResetResponse", + "ErrorResponse", + "HealthResponse", + "MotorActionFrame", + "MotorDescription", + "MotorStateFrame", + "ObservationFrame", + "ObservationKind", + "ProtocolVersion", + "RobotMotorSurface", + "RuntimeDescription", + "ScoreOutput", + "StepRequest", + "StepResponse", + "check_compatible", +] diff --git a/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/codecs.py b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/codecs.py new file mode 100644 index 0000000000..9188872751 --- /dev/null +++ b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/codecs.py @@ -0,0 +1,67 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Codec helpers for protocol models.""" + +from __future__ import annotations + +from collections.abc import Callable +import json +from typing import TypeVar + +from pydantic import BaseModel + +ProtocolModel = TypeVar("ProtocolModel", bound=BaseModel) + + +def to_json_bytes(model: BaseModel) -> bytes: + """Serialize a protocol model to UTF-8 JSON bytes.""" + + return model.model_dump_json().encode("utf-8") + + +def from_json_bytes(data: bytes, model_type: type[ProtocolModel]) -> ProtocolModel: + """Deserialize UTF-8 JSON bytes into a protocol model.""" + + return model_type.model_validate_json(data) + + +def to_json_dict(model: BaseModel) -> dict[str, object]: + """Serialize to a JSON-compatible dictionary for HTTP clients.""" + + return json.loads(model.model_dump_json()) + + +def to_msgpack_bytes(model: BaseModel) -> bytes: + """Serialize a protocol model to msgpack bytes when msgpack is installed.""" + + try: + import msgpack + except ImportError as exc: # pragma: no cover - depends on optional extra + raise RuntimeError("Install dimos-runtime-protocol[msgpack] to use msgpack codecs") from exc + return msgpack.packb(to_json_dict(model), use_bin_type=True) + + +def from_msgpack_bytes(data: bytes, model_type: type[ProtocolModel]) -> ProtocolModel: + """Deserialize msgpack bytes into a protocol model when msgpack is installed.""" + + try: + import msgpack + except ImportError as exc: # pragma: no cover - depends on optional extra + raise RuntimeError("Install dimos-runtime-protocol[msgpack] to use msgpack codecs") from exc + payload = msgpack.unpackb(data, raw=False) + return model_type.model_validate(payload) + + +ModelDecoder = Callable[[bytes], ProtocolModel] diff --git a/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/compat.py b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/compat.py new file mode 100644 index 0000000000..4131854bc9 --- /dev/null +++ b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/compat.py @@ -0,0 +1,74 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Protocol version compatibility helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from itertools import zip_longest + +from dimos_runtime_protocol.models import ProtocolVersion, RuntimeDescription +from dimos_runtime_protocol.version import PROTOCOL_VERSION + + +@dataclass(frozen=True) +class CompatibilityResult: + """Compatibility check outcome.""" + + compatible: bool + reason: str = "" + + +def _parts(version: str) -> tuple[int, ...]: + values: list[int] = [] + for part in version.split("."): + if not part.isdigit(): + break + values.append(int(part)) + return tuple(values) + + +def _less_than(left: str, right: str) -> bool: + for l_value, r_value in zip_longest(_parts(left), _parts(right), fillvalue=0): + if l_value < r_value: + return True + if l_value > r_value: + return False + return False + + +def check_compatible( + runtime: RuntimeDescription | ProtocolVersion, + *, + client_version: str = PROTOCOL_VERSION, +) -> CompatibilityResult: + """Check whether a sidecar protocol version can talk to this client.""" + + protocol = runtime.protocol if isinstance(runtime, RuntimeDescription) else runtime + if _less_than(client_version, protocol.min_compatible): + return CompatibilityResult( + compatible=False, + reason=( + f"client protocol {client_version} is older than sidecar minimum " + f"{protocol.min_compatible}" + ), + ) + if _parts(client_version)[:1] != _parts(protocol.version)[:1]: + return CompatibilityResult( + compatible=False, + reason=f"protocol major mismatch: client={client_version} sidecar={protocol.version}", + ) + return CompatibilityResult(compatible=True) diff --git a/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/models.py b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/models.py new file mode 100644 index 0000000000..fdd3bb911a --- /dev/null +++ b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/models.py @@ -0,0 +1,220 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pydantic models for the DimOS runtime sidecar protocol.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, NonNegativeFloat, NonNegativeInt, PositiveInt + +from dimos_runtime_protocol.types import JsonObject, JsonValue +from dimos_runtime_protocol.version import PROTOCOL_VERSION + + +class StrictModel(BaseModel): + """Base model that rejects drift between client and sidecar schemas.""" + + model_config = ConfigDict(extra="forbid") + + +class ProtocolVersion(StrictModel): + """Protocol version advertised by a client or runtime sidecar.""" + + version: str = PROTOCOL_VERSION + min_compatible: str = PROTOCOL_VERSION + + +class CommandMode(StrEnum): + """Supported motor command modes.""" + + POSITION = "position" + VELOCITY = "velocity" + TORQUE = "torque" + PD_TAU = "pd_tau" + + +class ObservationKind(StrEnum): + """Kinds of observations that can cross the runtime protocol.""" + + IMAGE = "image" + DEPTH = "depth" + SEGMENTATION = "segmentation" + STATE = "state" + TEXT = "text" + + +class HealthResponse(StrictModel): + """Health check response for sidecar readiness.""" + + ok: bool + runtime_id: str + protocol: ProtocolVersion = Field(default_factory=ProtocolVersion) + detail: str = "" + + +class MotorDescription(StrictModel): + """A single ordered motor exposed by a robot runtime.""" + + name: str + index: NonNegativeInt + units: Literal["rad", "m"] = "rad" + lower: float | None = None + upper: float | None = None + + +class RobotMotorSurface(StrictModel): + """Ordered whole-body motor surface for one robot.""" + + robot_id: str + surface_type: Literal["whole_body"] = "whole_body" + motors: list[MotorDescription] + supported_command_modes: list[CommandMode] = Field(default_factory=lambda: [CommandMode.POSITION]) + state_fields: list[Literal["q", "dq", "tau"]] = Field(default_factory=lambda: ["q", "dq", "tau"]) + + +class RuntimeDescription(StrictModel): + """Runtime metadata returned by a sidecar before DimOS launch.""" + + runtime_id: str + backend: str + protocol: ProtocolVersion = Field(default_factory=ProtocolVersion) + capabilities: list[str] = Field(default_factory=list) + robot_surfaces: list[RobotMotorSurface] + control_step_hz: PositiveInt + observation_streams: list[str] = Field(default_factory=list) + metadata: JsonObject = Field(default_factory=dict) + + +class EpisodeResetRequest(StrictModel): + """Request to reset a backend episode.""" + + episode_id: str + task_id: str + seed: int | None = None + options: JsonObject = Field(default_factory=dict) + + +class EpisodeResetResponse(StrictModel): + """Response after sidecar episode reset.""" + + episode_id: str + runtime_description: RuntimeDescription + observations: list["ObservationFrame"] = Field(default_factory=list) + + +class MotorActionFrame(StrictModel): + """Ordered motor command frame sent to a sidecar.""" + + robot_id: str + mode: CommandMode = CommandMode.POSITION + names: list[str] + q: list[float] + dq: list[float] = Field(default_factory=list) + kp: list[float] = Field(default_factory=list) + kd: list[float] = Field(default_factory=list) + tau: list[float] = Field(default_factory=list) + sequence: NonNegativeInt = 0 + + +class MotorStateFrame(StrictModel): + """Ordered motor state frame returned by a sidecar.""" + + robot_id: str + names: list[str] + q: list[float] + dq: list[float] + tau: list[float] + sequence: NonNegativeInt = 0 + timestamp_s: NonNegativeFloat = 0.0 + + +class ObservationFrame(StrictModel): + """Backend-neutral observation metadata or small payload.""" + + stream: str + kind: ObservationKind + encoding: str = "" + shape: list[int] = Field(default_factory=list) + dtype: str = "" + data_ref: str | None = None + inline_text: str | None = None + metadata: JsonObject = Field(default_factory=dict) + + +class StepRequest(StrictModel): + """One synchronous runtime step request.""" + + episode_id: str + tick_id: NonNegativeInt + action: MotorActionFrame + + +class StepResponse(StrictModel): + """One synchronous runtime step response.""" + + episode_id: str + tick_id: NonNegativeInt + motor_state: MotorStateFrame + observations: list[ObservationFrame] = Field(default_factory=list) + reward: float = 0.0 + done: bool = False + success: bool | None = None + info: JsonObject = Field(default_factory=dict) + + +class ScoreOutput(StrictModel): + """Normalized score metadata for an episode.""" + + episode_id: str + success: bool + score: float + reason: str = "" + metrics: JsonObject = Field(default_factory=dict) + + +class ArtifactOutput(StrictModel): + """Sidecar-produced artifact metadata.""" + + episode_id: str + artifacts: dict[str, str] = Field(default_factory=dict) + logs: list[str] = Field(default_factory=list) + + +class ErrorResponse(StrictModel): + """Protocol-level error payload.""" + + code: str + message: str + detail: JsonObject = Field(default_factory=dict) + + +_TYPES_NAMESPACE = { + "JsonObject": JsonObject, + "JsonValue": JsonValue, + "ObservationFrame": ObservationFrame, +} + +for _model in ( + RuntimeDescription, + EpisodeResetRequest, + EpisodeResetResponse, + ObservationFrame, + StepResponse, + ScoreOutput, + ErrorResponse, +): + _model.model_rebuild(_types_namespace=_TYPES_NAMESPACE) diff --git a/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/types.py b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/types.py new file mode 100644 index 0000000000..fb15250aa6 --- /dev/null +++ b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/types.py @@ -0,0 +1,26 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared JSON-compatible type aliases for protocol metadata.""" + +from __future__ import annotations + +from typing_extensions import TypeAliasType + +JsonScalar = str | int | float | bool | None +JsonValue = TypeAliasType( + "JsonValue", + JsonScalar | list["JsonValue"] | dict[str, "JsonValue"], +) +JsonObject = dict[str, JsonValue] diff --git a/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/version.py b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/version.py new file mode 100644 index 0000000000..778b99cdca --- /dev/null +++ b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/version.py @@ -0,0 +1,17 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Protocol version constants.""" + +PROTOCOL_VERSION = "0.1" diff --git a/packages/dimos-runtime-protocol/tests/test_models.py b/packages/dimos-runtime-protocol/tests/test_models.py new file mode 100644 index 0000000000..9307c47a7b --- /dev/null +++ b/packages/dimos-runtime-protocol/tests/test_models.py @@ -0,0 +1,86 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos_runtime_protocol import ( + CommandMode, + MotorActionFrame, + MotorDescription, + ObservationFrame, + ObservationKind, + ProtocolVersion, + RobotMotorSurface, + RuntimeDescription, + StepRequest, + check_compatible, +) +from dimos_runtime_protocol.codecs import from_json_bytes, to_json_bytes +from pydantic import ValidationError +import pytest + + +def test_runtime_description_round_trip() -> None: + surface = RobotMotorSurface( + robot_id="panda", + motors=[MotorDescription(name="panda/joint1", index=0)], + ) + desc = RuntimeDescription( + runtime_id="fake", + backend="fake", + robot_surfaces=[surface], + control_step_hz=100, + ) + + decoded = from_json_bytes(to_json_bytes(desc), RuntimeDescription) + + assert decoded == desc + assert check_compatible(decoded).compatible + + +def test_invalid_step_request_rejected() -> None: + with pytest.raises(ValidationError): + StepRequest.model_validate({"episode_id": "ep", "tick_id": 1}) + + +def test_observation_frame_metadata() -> None: + frame = ObservationFrame( + stream="agentview", + kind=ObservationKind.IMAGE, + encoding="png", + shape=[64, 64, 3], + dtype="uint8", + data_ref="payloads/agentview_000001.png", + ) + + assert frame.kind == ObservationKind.IMAGE + assert frame.data_ref is not None + + +def test_action_extra_fields_rejected() -> None: + with pytest.raises(ValidationError): + MotorActionFrame.model_validate( + { + "robot_id": "panda", + "mode": CommandMode.POSITION, + "names": ["panda/joint1"], + "q": [0.0], + "unexpected": True, + } + ) + + +def test_incompatible_protocol_version_rejected() -> None: + result = check_compatible(ProtocolVersion(version="1.0", min_compatible="1.0")) + + assert not result.compatible + assert "major mismatch" in result.reason or "older" in result.reason diff --git a/pyproject.toml b/pyproject.toml index 0350f00e5e..d8cb93fd74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ build-backend = "setuptools.build_meta" include-package-data = false [tool.setuptools.packages.find] -where = ["."] -include = ["dimos*"] +where = [".", "packages/dimos-runtime-protocol/src"] +include = ["dimos*", "dimos_runtime_protocol*"] # Web frontends / vendored node_modules: not part of the Python runtime # (MANIFEST.in already prunes these from the sdist; this keeps their stray # .py modules out of the wheel too). @@ -136,7 +136,6 @@ dependencies = [ # DimSim scene client (dimos/simulation/dimsim/scene_client.py) imports `websocket` # at module load; DimSim is a non-extra-gated robot connection backend. "websocket-client>=1.8", - "roboplan[manipulation]>=0.0.100", ] @@ -238,11 +237,12 @@ unitree-dds = [ ] manipulation = [ - # Planning (Drake) + # Planning "drake==1.45.0; sys_platform == 'darwin' and platform_machine != 'aarch64'", "drake>=1.40.0; sys_platform != 'darwin' and platform_machine != 'aarch64'", "pin-pink>=4.2.0", "qpsolvers[proxqp]>=4.12.0", + "roboplan[manipulation]>=0.0.100", # Hardware SDKs "can-motor-control>=0.0.2; sys_platform == 'linux' and platform_machine == 'x86_64'", @@ -262,6 +262,7 @@ manipulation = [ # Other "matplotlib>=3.7.1", "pyyaml>=6.0", + ] grasp = [ diff --git a/scripts/benchmarks/demo_fake_runtime_sidecar.py b/scripts/benchmarks/demo_fake_runtime_sidecar.py new file mode 100644 index 0000000000..a312ae5a15 --- /dev/null +++ b/scripts/benchmarks/demo_fake_runtime_sidecar.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the fake runtime sidecar smoke demo with a real ControlCoordinator.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +REPO_ROOT = Path(__file__).resolve().parents[2] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +FAKE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-fake-runtime-sidecar" / "src" + +for package_src in (PROTOCOL_SRC, FAKE_SIDECAR_SRC): + sys.path.insert(0, str(package_src)) + +from dimos_runtime_protocol import EpisodeResetRequest, MotorActionFrame, StepRequest + +from dimos.benchmark.runtime.artifacts import write_json +from dimos.benchmark.runtime.config import ( + BenchmarkEpisodeConfig, + resolve_runtime_plan, +) +from dimos.control.components import HardwareComponent, HardwareType +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.hardware.whole_body.spec import MotorState +from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient +from dimos.simulation.runtime_client.shm_motor import MotorShmOwner + + +def _load_config(path: Path) -> BenchmarkEpisodeConfig: + return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) + + +def _sidecar_env() -> dict[str, str]: + env = dict(os.environ) + existing = env.get("PYTHONPATH", "") + paths = [str(PROTOCOL_SRC), str(FAKE_SIDECAR_SRC)] + if existing: + paths.append(existing) + env["PYTHONPATH"] = os.pathsep.join(paths) + return env + + +def _start_fake_sidecar(config: BenchmarkEpisodeConfig) -> subprocess.Popen[str]: + return subprocess.Popen( + [ + sys.executable, + "-m", + "dimos_fake_runtime_sidecar.server", + "--host", + config.runtime_host, + "--port", + str(config.runtime_port), + "--robot-id", + config.robot_id, + "--dof", + str(config.dof), + ], + cwd=REPO_ROOT, + env=_sidecar_env(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +def _command_frame(owner: MotorShmOwner, robot_id: str) -> tuple[int, MotorActionFrame]: + sequence, commands = owner.read_commands() + return sequence, MotorActionFrame( + robot_id=robot_id, + names=owner.motor_names, + q=[command.q for command in commands], + dq=[command.dq for command in commands], + kp=[command.kp for command in commands], + kd=[command.kd for command in commands], + tau=[command.tau for command in commands], + sequence=sequence, + ) + + +def _trace_summary(trace: list[dict[str, object]]) -> dict[str, object]: + final = trace[-1] if trace else {} + return { + "ticks": len(trace), + "first_command_sequence": trace[0].get("command_sequence") if trace else None, + "final_command_sequence": final.get("command_sequence"), + "final_state_sequence": final.get("state_sequence"), + "final_command_q": final.get("command_q"), + "final_state_q": final.get("state_q"), + } + + +def run_demo(config_path: Path) -> Path: + config = _load_config(config_path) + sidecar = _start_fake_sidecar(config) + client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") + owner: MotorShmOwner | None = None + coordinator: ControlCoordinator | None = None + sidecar_output = "" + cleanup_status: dict[str, object] = { + "coordinator_stopped": False, + "shm_unlinked": False, + "sidecar_stopped": False, + } + try: + health = client.wait_until_healthy(timeout_s=5.0) + description = client.describe() + plan = resolve_runtime_plan(config, description) + reset = client.reset( + EpisodeResetRequest( + episode_id=plan.episode_id, + task_id=plan.task_id, + seed=config.seed, + ) + ) + + owner = MotorShmOwner(plan.shm_key, plan.motor_names) + owner.write_state( + [MotorState(q=0.0) for _ in plan.motor_names], + sequence=0, + ) + + hardware = HardwareComponent( + hardware_id=plan.robot_id, + hardware_type=HardwareType.WHOLE_BODY, + joints=plan.motor_names, + adapter_type="benchmark_runtime", + address=plan.shm_key, + adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, + ) + task_name = f"servo_{plan.robot_id}" + coordinator = ControlCoordinator( + tick_rate=float(plan.control_step_hz), + publish_joint_state=False, + hardware=[hardware], + tasks=[ + TaskConfig( + name=task_name, + type="servo", + joint_names=plan.motor_names, + auto_start=True, + params={"timeout": 0.0, "default_positions": [0.0] * len(plan.motor_names)}, + ) + ], + ) + coordinator.start() + + trace: list[dict[str, object]] = [] + target = [plan.target_position] * len(plan.motor_names) + for tick in range(plan.ticks): + accepted = coordinator.task_invoke( + task_name, "set_target", {"positions": target, "t_now": None} + ) + if accepted is not True: + raise RuntimeError(f"servo task rejected target at tick {tick}") + time.sleep(1.0 / plan.control_step_hz) + command_sequence, action = _command_frame(owner, plan.robot_id) + response = client.step( + StepRequest(episode_id=plan.episode_id, tick_id=tick, action=action) + ) + owner.write_state( + [ + MotorState( + q=response.motor_state.q[i], + dq=response.motor_state.dq[i], + tau=response.motor_state.tau[i], + ) + for i in range(len(plan.motor_names)) + ], + sequence=response.motor_state.sequence, + ) + trace.append( + { + "tick": tick, + "command_sequence": command_sequence, + "state_sequence": response.motor_state.sequence, + "command_q": action.q, + "state_q": response.motor_state.q, + } + ) + + score = client.score() + if not score.success: + raise RuntimeError(f"fake runtime smoke demo failed score: {score.reason}") + + artifact_dir = (REPO_ROOT / plan.artifact_dir).resolve() + write_json(artifact_dir / "episode_config.json", config) + write_json(artifact_dir / "runtime_description.json", description) + write_json(artifact_dir / "resolved_runtime_plan.json", plan) + write_json(artifact_dir / "reset_response.json", reset) + write_json(artifact_dir / "motor_trace.json", trace) + write_json(artifact_dir / "protocol_trace_summary.json", _trace_summary(trace)) + write_json(artifact_dir / "score.json", score) + write_json(artifact_dir / "health.json", health) + return artifact_dir + finally: + if coordinator is not None: + try: + coordinator.stop() + cleanup_status["coordinator_stopped"] = True + except Exception as exc: + cleanup_status["coordinator_error"] = str(exc) + if owner is not None: + try: + owner.close() + owner.unlink() + cleanup_status["shm_unlinked"] = True + except Exception as exc: + cleanup_status["shm_error"] = str(exc) + sidecar.terminate() + try: + sidecar_output, _ = sidecar.communicate(timeout=2.0) + except subprocess.TimeoutExpired: + sidecar.kill() + sidecar_output, _ = sidecar.communicate(timeout=2.0) + cleanup_status["sidecar_returncode"] = sidecar.returncode + cleanup_status["sidecar_stopped"] = sidecar.returncode is not None + if config.artifact_dir: + sidecar_log = (REPO_ROOT / config.artifact_dir / "fake_sidecar.log").resolve() + sidecar_log.parent.mkdir(parents=True, exist_ok=True) + sidecar_log.write_text(sidecar_output) + write_json(sidecar_log.parent / "cleanup_status.json", cleanup_status) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", + type=Path, + default=REPO_ROOT + / "dimos" + / "benchmark" + / "runtime" + / "configs" + / "fake_runtime_smoke.json", + ) + args = parser.parse_args() + artifact_dir = run_demo(args.config) + print(json.dumps({"ok": True, "artifact_dir": str(artifact_dir)}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmarks/demo_rerun_color_smoke.py b/scripts/benchmarks/demo_rerun_color_smoke.py new file mode 100644 index 0000000000..d115751455 --- /dev/null +++ b/scripts/benchmarks/demo_rerun_color_smoke.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Publish deterministic color bars through the same DimOS→Rerun image path. + +This is a narrow smoke test for the camera visualization plumbing. It avoids +Robosuite entirely and checks each transport layer with known RGB values before +publishing them to Rerun: + +1. RGB array -> `.npy` bytes -> RGB array +2. RGB array -> DimOS Image LCM encode/decode -> RGB array +3. RGB array -> private DimOS LCM topics -> RerunBridgeModule +""" + +from __future__ import annotations + +import argparse +from io import BytesIO +import json +from pathlib import Path +import sys +import time + +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) +sys.path.insert(0, str(REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src")) +sys.path.insert(0, str(REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src")) + +from dimos.benchmark.runtime.artifacts import write_json +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from scripts.benchmarks.demo_robosuite_panda_lift import ( + RerunStreamPublisher, + _free_tcp_port, +) + + +def _color_bars(height: int, width: int) -> np.ndarray: + if height < 3 or width < 1: + raise ValueError("height must be >= 3 and width must be >= 1") + image = np.zeros((height, width, 3), dtype=np.uint8) + first = height // 3 + second = 2 * height // 3 + image[:first, :, :] = [255, 0, 0] + image[first:second, :, :] = [0, 255, 0] + image[second:, :, :] = [0, 0, 255] + return image + + +def _npy_round_trip(image: np.ndarray) -> np.ndarray: + buffer = BytesIO() + np.save(buffer, image, allow_pickle=False) + return np.load(BytesIO(buffer.getvalue()), allow_pickle=False) + + +def _lcm_round_trip(image: np.ndarray) -> np.ndarray: + encoded = Image.from_numpy(image, format=ImageFormat.RGB).lcm_encode() + decoded = Image.lcm_decode(encoded) + if decoded.format != ImageFormat.RGB: + raise AssertionError(f"expected RGB after LCM round trip, got {decoded.format}") + return decoded.data + + +def _pixel_summary(image: np.ndarray) -> dict[str, list[int]]: + return { + "top_left_rgb": [int(value) for value in image[0, 0, :3]], + "center_rgb": [int(value) for value in image[image.shape[0] // 2, image.shape[1] // 2, :3]], + "bottom_left_rgb": [int(value) for value in image[-1, 0, :3]], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--height", type=int, default=120) + parser.add_argument("--width", type=int, default=160) + parser.add_argument("--frames", type=int, default=60) + parser.add_argument("--hz", type=float, default=10.0) + parser.add_argument("--rerun-memory-limit", default="128MB") + parser.add_argument("--rerun-grpc-port", type=int, default=0) + parser.add_argument("--rerun-lcm-port", type=int, default=0) + parser.add_argument( + "--artifact-dir", + type=Path, + default=REPO_ROOT / "artifacts" / "benchmark" / "rerun-color-smoke", + ) + args = parser.parse_args() + + source = _color_bars(args.height, args.width) + npy_decoded = _npy_round_trip(source) + lcm_decoded = _lcm_round_trip(npy_decoded) + npy_matches = bool(np.array_equal(source, npy_decoded)) + lcm_matches = bool(np.array_equal(source, lcm_decoded)) + if not npy_matches or not lcm_matches: + raise AssertionError( + f"color smoke mismatch: npy_matches={npy_matches}, lcm_matches={lcm_matches}" + ) + + grpc_port = args.rerun_grpc_port if args.rerun_grpc_port > 0 else _free_tcp_port() + lcm_port = args.rerun_lcm_port if args.rerun_lcm_port > 0 else _free_tcp_port() + publisher = RerunStreamPublisher( + grpc_port=grpc_port, + lcm_port=lcm_port, + memory_limit=args.rerun_memory_limit, + max_hz=args.hz, + topic_prefix="/rerun_color_smoke", + ) + published = 0 + try: + publisher.start() + period_s = 1.0 / args.hz if args.hz > 0.0 else 0.0 + for _ in range(args.frames): + publisher.publish_rgb(lcm_decoded, fov_y_deg=45.0, frame_id="color_smoke_camera") + published += 1 + if period_s > 0.0: + time.sleep(period_s) + finally: + publisher.stop() + + summary = { + "ok": True, + "expected_display": "top red, middle green, bottom blue; colors should not change over time", + "npy_matches": npy_matches, + "lcm_matches": lcm_matches, + "published_frames": published, + "height": args.height, + "width": args.width, + "rerun_grpc_port": grpc_port, + "rerun_lcm_port": lcm_port, + "rerun_memory_limit": args.rerun_memory_limit, + "source": _pixel_summary(source), + "npy_decoded": _pixel_summary(npy_decoded), + "lcm_decoded": _pixel_summary(lcm_decoded), + } + write_json(args.artifact_dir / "color_smoke_summary.json", summary) + print(json.dumps({"ok": True, "artifact_dir": str(args.artifact_dir), **summary}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmarks/demo_robosuite_camera_payload_smoke.py b/scripts/benchmarks/demo_robosuite_camera_payload_smoke.py new file mode 100644 index 0000000000..bae8cbfe83 --- /dev/null +++ b/scripts/benchmarks/demo_robosuite_camera_payload_smoke.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Smoke-test Robosuite camera payload transmission without Rerun. + +This verifies the suspect seam directly: + +Robosuite observation image in sidecar -> `.npy` payload store -> HTTP fetch -> +client `np.load` decode. + +The sidecar records source image hashes/statistics in the `ObservationFrame` +metadata before storing the payload. This script fetches the payload and verifies +the decoded array matches those metadata exactly. +""" + +from __future__ import annotations + +import argparse +import hashlib +from io import BytesIO +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[2] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +ROBOSUITE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src" + +for package_src in (PROTOCOL_SRC, ROBOSUITE_SIDECAR_SRC): + sys.path.insert(0, str(package_src)) + +from dimos_runtime_protocol import ( + EpisodeResetRequest, + MotorActionFrame, + ObservationKind, + StepRequest, +) + +from dimos.benchmark.runtime.artifacts import write_json +from dimos.benchmark.runtime.config import ( + BenchmarkEpisodeConfig, + resolve_runtime_plan, +) +from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient + + +def _load_config(path: Path) -> BenchmarkEpisodeConfig: + return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) + + +def _sidecar_env() -> dict[str, str]: + env = dict(os.environ) + existing = env.get("PYTHONPATH", "") + paths = [str(PROTOCOL_SRC), str(ROBOSUITE_SIDECAR_SRC)] + if existing: + paths.append(existing) + env["PYTHONPATH"] = os.pathsep.join(paths) + return env + + +def _start_sidecar(config: BenchmarkEpisodeConfig) -> subprocess.Popen[str]: + command = [ + sys.executable, + "-m", + "dimos_robosuite_sidecar.server", + "--host", + config.runtime_host, + "--port", + str(config.runtime_port), + "--env-name", + config.env_name, + "--robot-id", + config.robot_id, + "--robot-model", + config.robot_model, + "--controller", + config.controller, + "--control-freq", + str(config.control_step_hz), + "--horizon", + str(config.horizon), + "--camera-name", + config.camera_name, + "--seed", + str(config.seed) if config.seed is not None else "0", + ] + return subprocess.Popen( + command, + cwd=REPO_ROOT, + env=_sidecar_env(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +def _wait_healthy(sidecar: subprocess.Popen[str], client: RuntimeSidecarClient) -> object: + deadline = time.monotonic() + 20.0 + last_error = "" + while time.monotonic() < deadline: + if sidecar.poll() is not None: + raise RuntimeError("Robosuite sidecar exited before becoming healthy") + try: + return client.health() + except Exception as exc: + last_error = str(exc) + time.sleep(0.1) + raise RuntimeError(f"Robosuite sidecar did not become healthy: {last_error}") + + +def _array_sha256(array: np.ndarray) -> str: + return hashlib.sha256(np.ascontiguousarray(array).tobytes()).hexdigest() + + +def _payload_sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _pixel_summary(array: np.ndarray) -> dict[str, object]: + return { + "shape": [int(item) for item in array.shape], + "dtype": str(array.dtype), + "min": float(array.min()) if array.size else 0.0, + "max": float(array.max()) if array.size else 0.0, + "mean": float(array.mean()) if array.size else 0.0, + "top_left": [int(item) for item in array[0, 0].tolist()] if array.ndim == 3 else [], + "center": [int(item) for item in array[array.shape[0] // 2, array.shape[1] // 2].tolist()] + if array.ndim == 3 + else [], + "bottom_left": [int(item) for item in array[-1, 0].tolist()] if array.ndim == 3 else [], + } + + +def _write_jpeg(path: Path, rgb: np.ndarray) -> None: + import cv2 + + path.parent.mkdir(parents=True, exist_ok=True) + if rgb.ndim != 3 or rgb.shape[2] < 3: + raise ValueError(f"expected HxWx3 RGB image, got shape {rgb.shape}") + bgr = cv2.cvtColor(rgb[:, :, :3], cv2.COLOR_RGB2BGR) + if not cv2.imwrite(str(path), bgr): + raise RuntimeError(f"failed to write JPEG {path}") + + +def _validate_image_payload( + client: RuntimeSidecarClient, + frame: object, + *, + phase: str, + image_dir: Path, +) -> dict[str, object]: + data_ref = getattr(frame, "data_ref", None) + if not isinstance(data_ref, str): + raise AssertionError("image frame did not include data_ref") + metadata = getattr(frame, "metadata", {}) + if not isinstance(metadata, dict): + raise AssertionError("image frame metadata is not a dict") + payload = client.payload(data_ref) + payload_second_fetch = client.payload(data_ref) + if payload != payload_second_fetch: + raise AssertionError("re-fetching the same payload returned different bytes") + decoded = np.load(BytesIO(payload), allow_pickle=False) + decoded_second = np.load(BytesIO(payload_second_fetch), allow_pickle=False) + if not np.array_equal(decoded, decoded_second): + raise AssertionError("re-fetching the same payload decoded to different arrays") + + expected_shape = getattr(frame, "shape", None) + expected_dtype = getattr(frame, "dtype", None) + if expected_shape != [int(item) for item in decoded.shape]: + raise AssertionError(f"shape mismatch: metadata={expected_shape} decoded={decoded.shape}") + if expected_dtype != str(decoded.dtype): + raise AssertionError(f"dtype mismatch: metadata={expected_dtype} decoded={decoded.dtype}") + if metadata.get("array_sha256") != _array_sha256(decoded): + raise AssertionError("decoded array hash does not match sidecar source hash") + if metadata.get("payload_sha256") != _payload_sha256(payload): + raise AssertionError("payload hash does not match sidecar source payload hash") + + image_basename = f"{phase}_{getattr(frame, 'stream', 'camera')}" + raw_jpeg = image_dir / f"{image_basename}_raw.jpg" + _write_jpeg(raw_jpeg, decoded) + display_jpeg: Path | None = None + if metadata.get("image_convention") == "opengl": + display_jpeg = image_dir / f"{image_basename}_display_flipud.jpg" + _write_jpeg(display_jpeg, np.flipud(decoded)) + + return { + "stream": getattr(frame, "stream", ""), + "data_ref": data_ref, + "encoding": getattr(frame, "encoding", ""), + "same_payload_refetch_matches": True, + "raw_jpeg": str(raw_jpeg), + "display_jpeg": str(display_jpeg) if display_jpeg is not None else None, + "metadata": metadata, + "decoded": _pixel_summary(decoded), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", + type=Path, + default=REPO_ROOT + / "dimos" + / "benchmark" + / "runtime" + / "configs" + / "robosuite_panda_lift.json", + ) + parser.add_argument("--ticks", type=int, default=3) + parser.add_argument("--camera-name", default=None) + parser.add_argument( + "--artifact-dir", + type=Path, + default=REPO_ROOT / "artifacts" / "benchmark" / "robosuite-camera-payload-smoke", + ) + args = parser.parse_args() + + config = _load_config(args.config) + updates: dict[str, object] = { + "ticks": args.ticks, + "horizon": max(config.horizon, args.ticks + 1), + } + if args.camera_name is not None: + updates["camera_name"] = args.camera_name + config = BenchmarkEpisodeConfig.model_validate({**config.model_dump(), **updates}) + + sidecar = _start_sidecar(config) + client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") + sidecar_output = "" + checks: list[dict[str, object]] = [] + image_dir = args.artifact_dir / "images" + try: + health = _wait_healthy(sidecar, client) + description = client.describe() + plan = resolve_runtime_plan(config, description) + reset = client.reset( + EpisodeResetRequest(episode_id=plan.episode_id, task_id=plan.task_id, seed=config.seed) + ) + + for frame in reset.observations: + if getattr(frame, "kind", None) == ObservationKind.IMAGE: + phase = "reset" + checks.append( + { + "phase": phase, + **_validate_image_payload(client, frame, phase=phase, image_dir=image_dir), + } + ) + + zero_action = MotorActionFrame( + robot_id=plan.robot_id, names=plan.motor_names, q=[0.0] * len(plan.motor_names) + ) + for tick in range(plan.ticks): + response = client.step( + StepRequest(episode_id=plan.episode_id, tick_id=tick, action=zero_action) + ) + for frame in response.observations: + if getattr(frame, "kind", None) == ObservationKind.IMAGE: + phase = f"step_{tick}" + checks.append( + { + "phase": phase, + **_validate_image_payload( + client, frame, phase=phase, image_dir=image_dir + ), + } + ) + + summary = { + "ok": True, + "camera_name": config.camera_name, + "ticks": plan.ticks, + "checked_payloads": len(checks), + "image_dir": str(image_dir), + "checks": checks, + "health": getattr(health, "model_dump", lambda **_: health)(mode="json"), + "runtime_description": description.model_dump(mode="json"), + } + write_json(args.artifact_dir / "camera_payload_smoke_summary.json", summary) + print( + json.dumps( + { + "ok": True, + "artifact_dir": str(args.artifact_dir), + "checked_payloads": len(checks), + }, + indent=2, + ) + ) + finally: + sidecar.terminate() + try: + sidecar_output, _ = sidecar.communicate(timeout=2.0) + except subprocess.TimeoutExpired: + sidecar.kill() + sidecar_output, _ = sidecar.communicate(timeout=2.0) + args.artifact_dir.mkdir(parents=True, exist_ok=True) + (args.artifact_dir / "robosuite_sidecar.log").write_text(sidecar_output) + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmarks/demo_robosuite_panda_lift.py b/scripts/benchmarks/demo_robosuite_panda_lift.py new file mode 100644 index 0000000000..053b6c0180 --- /dev/null +++ b/scripts/benchmarks/demo_robosuite_panda_lift.py @@ -0,0 +1,730 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the Robosuite Panda Lift plumbing demo with a real ControlCoordinator. + +This script intentionally does not install or import Robosuite in the DimOS +process. It starts the Robosuite sidecar package in a subprocess and communicates +through the shared runtime protocol plus the local SHM motor bridge. +""" + +from __future__ import annotations + +import argparse +from io import BytesIO +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import time + +REPO_ROOT = Path(__file__).resolve().parents[2] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +ROBOSUITE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src" + +for package_src in (PROTOCOL_SRC, ROBOSUITE_SIDECAR_SRC): + sys.path.insert(0, str(package_src)) + +from dimos_runtime_protocol import ( + EpisodeResetRequest, + MotorActionFrame, + ObservationKind, + StepRequest, +) + +from dimos.benchmark.runtime.artifacts import write_json +from dimos.benchmark.runtime.config import ( + BenchmarkEpisodeConfig, + resolve_runtime_plan, +) +from dimos.control.components import HardwareComponent, HardwareType +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.hardware.whole_body.spec import MotorState +from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient +from dimos.simulation.runtime_client.shm_motor import MotorShmOwner + + +def _load_config(path: Path) -> BenchmarkEpisodeConfig: + return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) + + +def _sidecar_env() -> dict[str, str]: + env = dict(os.environ) + existing = env.get("PYTHONPATH", "") + paths = [str(PROTOCOL_SRC), str(ROBOSUITE_SIDECAR_SRC)] + if existing: + paths.append(existing) + env["PYTHONPATH"] = os.pathsep.join(paths) + return env + + +def _start_robosuite_sidecar( + config: BenchmarkEpisodeConfig, + *, + server_image_dump_dir: Path | None = None, + image_dump_every: int = 0, +) -> subprocess.Popen[str]: + command = [ + sys.executable, + "-m", + "dimos_robosuite_sidecar.server", + "--host", + config.runtime_host, + "--port", + str(config.runtime_port), + "--env-name", + config.env_name, + "--robot-id", + config.robot_id, + "--robot-model", + config.robot_model, + "--controller", + config.controller, + "--control-freq", + str(config.control_step_hz), + "--horizon", + str(config.horizon), + "--camera-name", + config.camera_name, + "--seed", + str(config.seed) if config.seed is not None else "0", + ] + if config.visualize: + command.append("--visualize") + if server_image_dump_dir is not None and image_dump_every > 0: + command.extend( + [ + "--image-dump-dir", + str(server_image_dump_dir), + "--image-dump-every", + str(image_dump_every), + ] + ) + return subprocess.Popen( + command, + cwd=REPO_ROOT, + env=_sidecar_env(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +def _wait_sidecar_healthy( + sidecar: subprocess.Popen[str], + client: RuntimeSidecarClient, + timeout_s: float, +) -> object: + deadline = time.monotonic() + timeout_s + last_error = "" + while time.monotonic() < deadline: + if sidecar.poll() is not None: + raise RuntimeError("Robosuite sidecar exited before becoming healthy") + try: + return client.health() + except Exception as exc: + last_error = str(exc) + time.sleep(0.1) + raise RuntimeError(f"Robosuite sidecar did not become healthy: {last_error}") + + +def _command_frame(owner: MotorShmOwner, robot_id: str) -> tuple[int, MotorActionFrame]: + sequence, commands = owner.read_commands() + return sequence, MotorActionFrame( + robot_id=robot_id, + names=owner.motor_names, + q=[command.q for command in commands], + dq=[command.dq for command in commands], + kp=[command.kp for command in commands], + kd=[command.kd for command in commands], + tau=[command.tau for command in commands], + sequence=sequence, + ) + + +def _free_tcp_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _lcm_url(port: int) -> str: + return f"udpm://239.255.76.67:{port}?ttl=0" + + +class RerunStreamPublisher: + """Publish runtime camera observations through DimOS streams for Rerun.""" + + def __init__( + self, + *, + grpc_port: int, + lcm_port: int, + memory_limit: str, + max_hz: float, + topic_prefix: str = "/robosuite_runtime", + ) -> None: + from dimos.core.transport import LCMTransport + from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo + from dimos.msgs.sensor_msgs.Image import Image + from dimos.protocol.pubsub.impl.lcmpubsub import LCM + from dimos.visualization.rerun.bridge import RerunBridgeModule + + prefix = topic_prefix.rstrip("/") + self._image_topic = f"{prefix}/color_image" + self._camera_info_topic = f"{prefix}/camera_info" + self._image_entity = "world/robosuite_runtime/color_image" + self._camera_info_entity = "world/robosuite_runtime/camera_info" + self._camera_info_published = False + + def runtime_camera_blueprint() -> object: + import rerun.blueprint as rrb + + return rrb.Blueprint( + rrb.Vertical( + rrb.Spatial2DView(origin=self._image_entity, name="Robosuite camera"), + ) + ) + + def topic_to_entity(topic: object) -> str: + topic_name = getattr(topic, "topic", None) + if not isinstance(topic_name, str): + topic_name = getattr(topic, "name", None) + if not isinstance(topic_name, str): + topic_name = str(topic).split("#")[0] + if topic_name == self._image_topic: + return self._image_entity + if topic_name == self._camera_info_topic: + return self._camera_info_entity + return f"world{topic_name}" + + max_hz_by_entity = {self._image_entity: max_hz} if max_hz > 0.0 else {} + lcm_url = _lcm_url(lcm_port) + + self._image_transport = LCMTransport(self._image_topic, Image, url=lcm_url) + self._camera_info_transport = LCMTransport(self._camera_info_topic, CameraInfo, url=lcm_url) + self._bridge = RerunBridgeModule( + pubsubs=[LCM(url=lcm_url)], + blueprint=runtime_camera_blueprint, + connect_url=f"rerun+http://127.0.0.1:{grpc_port}/proxy", + memory_limit=memory_limit, + max_hz=max_hz_by_entity, + topic_to_entity=topic_to_entity, + visual_override={ + self._camera_info_entity: lambda camera_info: camera_info.to_rerun( + image_topic=self._image_entity + ), + }, + ) + + def start(self) -> None: + self._bridge.start() + + def stop(self) -> None: + self._image_transport.stop() + self._camera_info_transport.stop() + self._bridge.stop() + + def publish_rgb(self, rgb: object, *, fov_y_deg: float, frame_id: str) -> None: + import numpy as np + + from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo + from dimos.msgs.sensor_msgs.Image import Image, ImageFormat + + array = np.asarray(rgb) + if array.ndim != 3 or array.shape[2] < 3: + raise ValueError(f"expected HxWx3 RGB image, got shape {array.shape}") + # Leave Image.frame_id empty because the Rerun bridge logs the image at + # the canonical entity path (`world/color_image`) while CameraInfo logs + # the pinhole for that same entity. Setting a frame_id on the image would + # attach a second transform parent and Rerun rejects that. + image = Image.from_numpy(array[:, :, :3], format=ImageFormat.RGB, frame_id="") + camera_info = CameraInfo.from_fov( + fov_y_deg, + width=image.width, + height=image.height, + axis="vertical", + frame_id=frame_id, + ).with_ts(image.ts) + self._image_transport.broadcast(None, image) + if not self._camera_info_published: + self._camera_info_transport.broadcast(None, camera_info) + self._camera_info_published = True + + +def _publish_rerun_observations( + client: RuntimeSidecarClient, + response_observations: object, + publisher: RerunStreamPublisher | None, + *, + image_dump_dir: Path | None = None, + image_dump_label: str = "frame", +) -> int: + if publisher is None and image_dump_dir is None: + return 0 + if not isinstance(response_observations, list): + return 0 + published = 0 + for frame in response_observations: + kind = getattr(frame, "kind", None) + data_ref = getattr(frame, "data_ref", None) + if kind != ObservationKind.IMAGE: + continue + if not isinstance(data_ref, str): + continue + payload = client.payload(data_ref) + import numpy as np + + image = np.load(BytesIO(payload), allow_pickle=False) + metadata = getattr(frame, "metadata", {}) + fov_y_deg = 45.0 + if isinstance(metadata, dict): + maybe_fov = metadata.get("fov_y_deg") + if isinstance(maybe_fov, int | float): + fov_y_deg = float(maybe_fov) + if metadata.get("image_convention") == "opengl": + display_image = np.flipud(image) + else: + display_image = image + else: + display_image = image + stream = getattr(frame, "stream", "camera") + frame_id = stream if isinstance(stream, str) else "camera" + if image_dump_dir is not None: + _write_rgb_jpeg(image_dump_dir / f"{image_dump_label}_{frame_id}_raw.jpg", image) + _write_rgb_jpeg( + image_dump_dir / f"{image_dump_label}_{frame_id}_display.jpg", + display_image, + ) + if publisher is not None: + publisher.publish_rgb(display_image, fov_y_deg=fov_y_deg, frame_id=frame_id) + published += 1 + return published + + +def _write_rgb_jpeg(path: Path, rgb: object) -> None: + import cv2 + import numpy as np + + array = np.asarray(rgb) + if array.ndim != 3 or array.shape[2] < 3: + raise ValueError(f"expected HxWx3 RGB image, got shape {array.shape}") + path.parent.mkdir(parents=True, exist_ok=True) + bgr = cv2.cvtColor(array[:, :, :3], cv2.COLOR_RGB2BGR) + if not cv2.imwrite(str(path), bgr): + raise RuntimeError(f"failed to write JPEG {path}") + + +def _trace_summary(trace: list[dict[str, object]]) -> dict[str, object]: + final = trace[-1] if trace else {} + stream_set: set[str] = set() + for entry in trace: + value = entry.get("observation_streams", []) + if isinstance(value, list): + stream_set.update(stream for stream in value if isinstance(stream, str)) + streams = sorted(stream_set) + return { + "ticks": len(trace), + "first_command_sequence": trace[0].get("command_sequence") if trace else None, + "final_command_sequence": final.get("command_sequence"), + "final_state_sequence": final.get("state_sequence"), + "final_command_q": final.get("command_q"), + "final_state_q": final.get("state_q"), + "observation_streams": streams, + "final_reward": final.get("reward"), + "final_done": final.get("done"), + "final_success": final.get("success"), + } + + +def _target_for_tick(plan_target: float, motor_count: int, tick: int, visual: bool) -> list[float]: + if not visual: + return [plan_target] * motor_count + # Deliberately obvious visual command: hold a large normalized target long + # enough for the on-screen Robosuite viewer to show the arm moving, then + # alternate direction. Robosuite clips normalized JOINT_POSITION inputs to + # [-1, 1], so this stays inside the controller action range. + phase = 1.0 if (tick // 120) % 2 == 0 else -1.0 + amplitude = max(abs(plan_target), 0.9) + arm_pattern = [1.0, -0.9, 0.8, -0.7, 0.6, -0.5, 0.4] + targets = [amplitude * phase * scale for scale in arm_pattern[:motor_count]] + if motor_count > 7: + gripper_phase = 1.0 if (tick // 80) % 2 == 0 else -1.0 + targets.extend([0.6 * gripper_phase] * (motor_count - 7)) + return targets[:motor_count] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", + type=Path, + default=REPO_ROOT + / "dimos" + / "benchmark" + / "runtime" + / "configs" + / "robosuite_panda_lift.json", + ) + parser.add_argument( + "--visual", + action="store_true", + help="Open the Robosuite viewer and run a longer moving command sequence.", + ) + parser.add_argument("--ticks", type=int, default=None, help="Override configured tick count.") + parser.add_argument( + "--horizon", type=int, default=None, help="Override Robosuite episode horizon." + ) + parser.add_argument( + "--target-position", + type=float, + default=None, + help="Override configured command amplitude/target.", + ) + parser.add_argument( + "--camera-name", + default=None, + help="Override Robosuite camera, e.g. agentview or robot0_eye_in_hand.", + ) + parser.add_argument( + "--rerun", + action="store_true", + help="Publish Robosuite camera payloads to Rerun through DimOS streams.", + ) + parser.add_argument( + "--rerun-memory-limit", + default="128MB", + help="Memory cap for the Rerun server/viewer used by --rerun.", + ) + parser.add_argument( + "--rerun-grpc-port", + type=int, + default=0, + help="Rerun gRPC port for --rerun. 0 selects a free port to avoid mixing with old recordings.", + ) + parser.add_argument( + "--rerun-lcm-port", + type=int, + default=0, + help="Private LCM multicast port for --rerun streams. 0 selects a free port to avoid mixing with other LCM traffic.", + ) + parser.add_argument( + "--rerun-max-hz", + type=float, + default=10.0, + help="Maximum image publish rate to Rerun. Use <=0 for every simulator tick.", + ) + parser.add_argument( + "--camera-jpeg-dump-every", + type=int, + default=25, + help=( + "When --rerun is enabled, write every Nth fetched Robosuite camera payload " + "as raw/display JPEGs under artifacts/.../images. Use 1 for every tick, <=0 to disable." + ), + ) + args = parser.parse_args() + try: + config = _load_config(args.config) + updates: dict[str, object] = {} + if args.visual: + updates["visualize"] = True + ticks = args.ticks if args.ticks is not None else max(config.ticks, 600) + updates["ticks"] = ticks + updates["horizon"] = ( + args.horizon if args.horizon is not None else max(config.horizon, ticks + 1) + ) + updates["target_position"] = ( + args.target_position + if args.target_position is not None + else max(abs(config.target_position), 0.9) + ) + else: + if args.ticks is not None: + updates["ticks"] = args.ticks + if args.horizon is not None: + updates["horizon"] = args.horizon + if args.target_position is not None: + updates["target_position"] = args.target_position + if args.camera_name is not None: + updates["camera_name"] = args.camera_name + if updates: + config = BenchmarkEpisodeConfig.model_validate({**config.model_dump(), **updates}) + artifact_dir = run_demo_config( + config, + rerun=args.rerun, + rerun_memory_limit=args.rerun_memory_limit, + rerun_grpc_port=args.rerun_grpc_port, + rerun_lcm_port=args.rerun_lcm_port, + rerun_max_hz=args.rerun_max_hz, + camera_jpeg_dump_every=args.camera_jpeg_dump_every, + ) + except Exception as exc: + print(json.dumps({"ok": False, "reason": str(exc)}, indent=2)) + sys.exit(2) + print(json.dumps({"ok": True, "artifact_dir": str(artifact_dir)}, indent=2)) + + +def run_demo_config( + config: BenchmarkEpisodeConfig, + *, + rerun: bool = False, + rerun_memory_limit: str = "128MB", + rerun_grpc_port: int = 0, + rerun_lcm_port: int = 0, + rerun_max_hz: float = 10.0, + camera_jpeg_dump_every: int = 25, +) -> Path: + return _run_demo( + config, + rerun=rerun, + rerun_memory_limit=rerun_memory_limit, + rerun_grpc_port=rerun_grpc_port, + rerun_lcm_port=rerun_lcm_port, + rerun_max_hz=rerun_max_hz, + camera_jpeg_dump_every=camera_jpeg_dump_every, + ) + + +def run_demo(config_path: Path) -> Path: + return _run_demo( + _load_config(config_path), + rerun=False, + rerun_memory_limit="128MB", + rerun_grpc_port=0, + rerun_lcm_port=0, + rerun_max_hz=10.0, + camera_jpeg_dump_every=25, + ) + + +def _run_demo( + config: BenchmarkEpisodeConfig, + *, + rerun: bool, + rerun_memory_limit: str, + rerun_grpc_port: int, + rerun_lcm_port: int, + rerun_max_hz: float, + camera_jpeg_dump_every: int, +) -> Path: + artifact_dir = (REPO_ROOT / config.artifact_dir).resolve() + client_image_dump_dir = artifact_dir / "images" / "client" + server_image_dump_dir = artifact_dir / "images" / "server" + sidecar = _start_robosuite_sidecar( + config, + server_image_dump_dir=server_image_dump_dir if camera_jpeg_dump_every > 0 else None, + image_dump_every=camera_jpeg_dump_every, + ) + client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") + owner: MotorShmOwner | None = None + coordinator: ControlCoordinator | None = None + rerun_publisher: RerunStreamPublisher | None = None + sidecar_output = "" + cleanup_status: dict[str, object] = { + "coordinator_stopped": False, + "shm_unlinked": False, + "sidecar_stopped": False, + } + selected_rerun_grpc_port: int | None = None + selected_rerun_lcm_port: int | None = None + try: + try: + health = _wait_sidecar_healthy(sidecar, client, timeout_s=20.0) + except RuntimeError as exc: + raise RuntimeError( + "Robosuite sidecar did not become healthy. If this environment does not " + "have Robosuite installed, run this script in the Robosuite sidecar env." + ) from exc + description = client.describe() + plan = resolve_runtime_plan(config, description) + reset = client.reset( + EpisodeResetRequest( + episode_id=plan.episode_id, + task_id=plan.task_id, + seed=config.seed, + ) + ) + + owner = MotorShmOwner(plan.shm_key, plan.motor_names) + owner.write_state([MotorState(q=0.0) for _ in plan.motor_names], sequence=0) + + hardware = HardwareComponent( + hardware_id=plan.robot_id, + hardware_type=HardwareType.WHOLE_BODY, + joints=plan.motor_names, + adapter_type="benchmark_runtime", + address=plan.shm_key, + adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, + ) + task_name = f"servo_{plan.robot_id}" + coordinator = ControlCoordinator( + tick_rate=float(plan.control_step_hz), + publish_joint_state=False, + hardware=[hardware], + tasks=[ + TaskConfig( + name=task_name, + type="servo", + joint_names=plan.motor_names, + auto_start=True, + params={"timeout": 0.0, "default_positions": [0.0] * len(plan.motor_names)}, + ) + ], + ) + coordinator.start() + if rerun: + selected_rerun_grpc_port = rerun_grpc_port if rerun_grpc_port > 0 else _free_tcp_port() + selected_rerun_lcm_port = rerun_lcm_port if rerun_lcm_port > 0 else _free_tcp_port() + rerun_publisher = RerunStreamPublisher( + grpc_port=selected_rerun_grpc_port, + lcm_port=selected_rerun_lcm_port, + memory_limit=rerun_memory_limit, + max_hz=rerun_max_hz, + ) + rerun_publisher.start() + if camera_jpeg_dump_every > 0: + _publish_rerun_observations( + client, + reset.observations, + None, + image_dump_dir=client_image_dump_dir, + image_dump_label="reset", + ) + + trace: list[dict[str, object]] = [] + published_rerun_frames = 0 + for tick in range(plan.ticks): + target = _target_for_tick( + plan.target_position, len(plan.motor_names), tick, config.visualize + ) + accepted = coordinator.task_invoke( + task_name, "set_target", {"positions": target, "t_now": None} + ) + if accepted is not True: + raise RuntimeError(f"servo task rejected target at tick {tick}") + time.sleep(1.0 / plan.control_step_hz) + command_sequence, action = _command_frame(owner, plan.robot_id) + response = client.step( + StepRequest(episode_id=plan.episode_id, tick_id=tick, action=action) + ) + should_dump_jpeg = ( + rerun and camera_jpeg_dump_every > 0 and tick % camera_jpeg_dump_every == 0 + ) + published_rerun_frames += _publish_rerun_observations( + client, + response.observations, + rerun_publisher, + image_dump_dir=client_image_dump_dir if should_dump_jpeg else None, + image_dump_label=f"tick_{tick:06d}", + ) + owner.write_state( + [ + MotorState( + q=response.motor_state.q[i], + dq=response.motor_state.dq[i], + tau=response.motor_state.tau[i], + ) + for i in range(len(plan.motor_names)) + ], + sequence=response.motor_state.sequence, + ) + trace.append( + { + "tick": tick, + "command_sequence": command_sequence, + "state_sequence": response.motor_state.sequence, + "command_q": action.q, + "state_q": response.motor_state.q, + "observation_streams": [frame.stream for frame in response.observations], + "rerun_frames_published": published_rerun_frames, + "reward": response.reward, + "done": response.done, + "success": response.success, + } + ) + if response.done: + break + + if config.visualize: + print("visual demo complete; keeping Robosuite viewer open for 5 seconds") + time.sleep(5.0) + + score = client.score() + write_json(artifact_dir / "episode_config.json", config) + write_json(artifact_dir / "runtime_description.json", description) + write_json(artifact_dir / "resolved_runtime_plan.json", plan) + write_json(artifact_dir / "reset_response.json", reset) + write_json(artifact_dir / "motor_trace.json", trace) + write_json(artifact_dir / "protocol_trace_summary.json", _trace_summary(trace)) + write_json( + artifact_dir / "rerun_summary.json", + { + "enabled": rerun, + "frames_published": published_rerun_frames, + "memory_limit": rerun_memory_limit, + "max_hz": rerun_max_hz, + "grpc_port": selected_rerun_grpc_port, + "lcm_port": selected_rerun_lcm_port, + "client_jpeg_dump_dir": str(client_image_dump_dir) + if rerun and camera_jpeg_dump_every > 0 + else None, + "server_jpeg_dump_dir": str(server_image_dump_dir) + if camera_jpeg_dump_every > 0 + else None, + "jpeg_dump_every": camera_jpeg_dump_every, + }, + ) + write_json(artifact_dir / "score.json", score) + write_json(artifact_dir / "health.json", health) + return artifact_dir + finally: + if rerun_publisher is not None: + try: + rerun_publisher.stop() + cleanup_status["rerun_stopped"] = True + except Exception as exc: + cleanup_status["rerun_error"] = str(exc) + if coordinator is not None: + try: + coordinator.stop() + cleanup_status["coordinator_stopped"] = True + except Exception as exc: + cleanup_status["coordinator_error"] = str(exc) + if owner is not None: + try: + owner.close() + owner.unlink() + cleanup_status["shm_unlinked"] = True + except Exception as exc: + cleanup_status["shm_error"] = str(exc) + sidecar.terminate() + try: + sidecar_output, _ = sidecar.communicate(timeout=2.0) + except subprocess.TimeoutExpired: + sidecar.kill() + sidecar_output, _ = sidecar.communicate(timeout=2.0) + cleanup_status["sidecar_returncode"] = sidecar.returncode + cleanup_status["sidecar_stopped"] = sidecar.returncode is not None + sidecar_log = artifact_dir / "robosuite_sidecar.log" + sidecar_log.parent.mkdir(parents=True, exist_ok=True) + sidecar_log.write_text(sidecar_output) + write_json(sidecar_log.parent / "cleanup_status.json", cleanup_status) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 643cbb1777..ba5d85e79d 100644 --- a/uv.lock +++ b/uv.lock @@ -2017,7 +2017,6 @@ dependencies = [ { name = "pyturbojpeg" }, { name = "reactivex" }, { name = "rerun-sdk" }, - { name = "roboplan" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sortedcontainers" }, @@ -2095,6 +2094,7 @@ all = [ { name = "qpsolvers", extra = ["proxqp"] }, { name = "reportlab" }, { name = "rerun-sdk" }, + { name = "roboplan" }, { name = "sounddevice" }, { name = "soundfile" }, { name = "sse-starlette" }, @@ -2176,6 +2176,7 @@ manipulation = [ { name = "pyrealsense2-extended", marker = "sys_platform != 'darwin'" }, { name = "pyyaml" }, { name = "qpsolvers", extra = ["proxqp"] }, + { name = "roboplan" }, { name = "trimesh" }, { name = "viser", extra = ["urdf"] }, { name = "xacro" }, @@ -2528,7 +2529,7 @@ requires-dist = [ { name = "reportlab", marker = "extra == 'apriltag'", specifier = ">=4.5.0" }, { name = "rerun-sdk", specifier = "==0.32.0a1" }, { name = "rerun-sdk", marker = "extra == 'visualization'", specifier = "==0.32.0a1" }, - { name = "roboplan", extras = ["manipulation"], specifier = ">=0.0.100" }, + { name = "roboplan", extras = ["manipulation"], marker = "extra == 'manipulation'", specifier = ">=0.0.100" }, { name = "scipy", specifier = ">=1.15.1" }, { name = "sortedcontainers", specifier = "==2.4.0" }, { name = "sounddevice", marker = "extra == 'agents'" }, From 3af4e7d3d69a2d3416fb40c594669689683fa88c Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 17:01:45 -0700 Subject: [PATCH 084/110] spec: venv modules --- .gitignore | 3 + .../venv-deployable-modules/.openspec.yaml | 2 + .../changes/venv-deployable-modules/design.md | 143 ++++++++++++++++++ .../venv-deployable-modules/proposal.md | 33 ++++ .../runtime-environment-registry/spec.md | 45 ++++++ .../specs/venv-module-packaging/spec.md | 45 ++++++ .../specs/venv-module-placement/spec.md | 60 ++++++++ .../changes/venv-deployable-modules/tasks.md | 52 +++++++ 8 files changed, 383 insertions(+) create mode 100644 openspec/changes/venv-deployable-modules/.openspec.yaml create mode 100644 openspec/changes/venv-deployable-modules/design.md create mode 100644 openspec/changes/venv-deployable-modules/proposal.md create mode 100644 openspec/changes/venv-deployable-modules/specs/runtime-environment-registry/spec.md create mode 100644 openspec/changes/venv-deployable-modules/specs/venv-module-packaging/spec.md create mode 100644 openspec/changes/venv-deployable-modules/specs/venv-module-placement/spec.md create mode 100644 openspec/changes/venv-deployable-modules/tasks.md diff --git a/.gitignore b/.gitignore index 4b1c140077..484a73e809 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ __pycache__/ # Ignore virtual environment directories *venv*/ .venv*/ +# Keep OpenSpec change artifacts trackable even when change names include ignored words. +!/openspec/changes/ +!/openspec/changes/** .ssh/ .direnv/ diff --git a/openspec/changes/venv-deployable-modules/.openspec.yaml b/openspec/changes/venv-deployable-modules/.openspec.yaml new file mode 100644 index 0000000000..578bd54974 --- /dev/null +++ b/openspec/changes/venv-deployable-modules/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-26 diff --git a/openspec/changes/venv-deployable-modules/design.md b/openspec/changes/venv-deployable-modules/design.md new file mode 100644 index 0000000000..eadbca6e3d --- /dev/null +++ b/openspec/changes/venv-deployable-modules/design.md @@ -0,0 +1,143 @@ +## Context + +`dimos run` currently creates a coordinator process that builds a blueprint, deploys Modules through `WorkerManagerPython`, and launches Python worker processes with `multiprocessing` forkserver. Those workers use a `Pipe` and pickled request/response dataclasses such as `DeployModuleRequest`, `CallMethodRequest`, and `WorkerResponse` to host one or more first-class DimOS Modules. + +This architecture gives DimOS a clean coordinator/worker split, but all Python workers currently run from the same Python environment as the coordinator. Any Module class imported during blueprint construction can also force optional hardware, simulation, perception, or native-adjacent dependencies into the coordinator environment. Native modules already avoid some of this pressure by wrapping separately built executables, often declared by module-local `flake.nix` files, but Python Modules do not have an equivalent same-machine isolated runtime path. + +The design focuses on same-machine separate Python environments first. Remote deployment, cluster scheduling, and transport changes are intentionally deferred, but the model should not block those later. + +## Goals / Non-Goals + +**Goals:** + +- Run selected first-class DimOS Python Modules inside worker processes launched from named Python virtual environments. +- Preserve current blueprint orchestration, module lifecycle, RPC, refs, stream wiring, and worker message semantics. +- Keep placement as a blueprint/runtime decision rather than a permanent property of a Module class. +- Provide a Python-first runtime environment registry that can also describe Nix-backed native runtime material. +- Use separately packaged Python module distributions with their own `pyproject.toml` dependency closure as the Python analog to native module `flake.nix` files. +- Keep current native module launching coherent by making runtime environments resolve executable/build/env material while `NativeModule` remains responsible for native subprocess lifecycle. +- Include a small demo that proves the coordinator can build and wire a blueprint without installing a dependency that exists only in a venv worker. + +**Non-Goals:** + +- True remote machine deployment or remote worker agents. +- Zenoh, Kubernetes-style scheduling, or new data-plane transports. +- Replacing the existing worker protocol with an HTTP/gRPC/sidecar protocol. +- Requiring all DimOS packages to be split before phase 1. +- Making YAML/TOML config files the primary user API for runtime environments. + +## Decisions + +### Preserve the DimOS worker protocol; abstract worker launch and channel handling + +The phase-1 implementation should keep the semantic worker protocol: + +- `DeployModuleRequest` +- `SetRefRequest` +- `GetAttrRequest` +- `CallMethodRequest` +- `UndeployModuleRequest` +- `SuppressConsoleRequest` +- `ShutdownRequest` +- `WorkerResponse` + +The current forkserver `Pipe` implementation should become one launch/channel implementation behind a worker process handle. Venv workers should launch with `subprocess.Popen( -m )` and connect to the coordinator through `multiprocessing.connection.Listener/Client`. + +Alternatives considered: + +- Stdio framed pickle protocol: rejected for phase 1 because normal process logs can corrupt control messages unless stdout/stderr isolation is perfect. +- New sidecar service protocol: rejected because venv workers are still local DimOS workers, not arbitrary services. +- Replacing pickled request objects immediately: deferred because the same-code-version assumption makes the existing protocol a lower-risk spike path. + +### Add worker launch abstractions + +Introduce a launch/process boundary with concepts equivalent to: + +- `WorkerLauncher`: starts a worker process for a particular launch environment. +- `WorkerProcessHandle`: owns send/recv, process liveness, shutdown, and cleanup. + +The default launcher uses the current forkserver process and `Pipe`. The venv launcher uses a configured Python interpreter and a multiprocessing connection channel. `WorkerManagerPython` can keep scheduling and capacity behavior while delegating launch mechanics to the appropriate launcher. + +Alternatives considered: + +- A separate `WorkerManagerVenv`: possible later, but phase 1 should avoid duplicating scheduling and pool semantics. +- One worker process per venv Module: rejected because current workers can host multiple compatible Modules and that behavior should be preserved. + +### Use blueprint-level placement into named runtime environments + +Venv selection belongs to blueprint/runtime configuration, not to the Module class. The same import-safe Module may run in the default worker pool on a developer machine, a sensor venv on a robot, or a fake/minimal venv in CI. + +Placement should reference a named runtime environment. Named runtime environments backed by Python venvs get dedicated worker pools; a pool may host multiple compatible Modules but MUST NOT mix Modules assigned to different Python environments. + +Alternatives considered: + +- `ModuleBase.deployment = "venv"`: rejected because it makes environment placement intrinsic to the class and overloads the current deployment backend concept. +- Hardcoded Python executable paths in blueprints: rejected because blueprints should remain portable topology/placement declarations. + +### Make runtime environments Python API first + +Runtime environments should be configured through typed Python objects first. Optional config-file loading can be added later as a convenience layer. + +The registry maps stable names to environment backends. Examples of backends: + +- current process environment +- Python venv environment +- Nix environment for native executable resolution +- future remote/container environments + +Consumers request only the capability they need. A venv worker launcher asks a Python venv environment for a Python interpreter and environment variables. A `NativeModule` asks a Nix environment for executable/build/cwd/env material. + +Alternatives considered: + +- A venv-specific config such as `venv_workers`: rejected as too narrow because Nix-backed native modules are part of the same runtime environment management problem. +- Primary YAML/TOML environment files: rejected for now because DimOS blueprints are Python-first and should stay directly composable. + +### Use separately packaged venv module distributions as declarative Python environment definitions + +Python venv-deployable modules should be packaged as separate Python distributions with their own `pyproject.toml`. That `pyproject.toml` declares the module's Python dependency closure, similar to how a native module's `flake.nix` declares native build/runtime closure. + +Phase 1 packages may depend on the current root `dimos` package plus module-specific dependencies. A later `dimos-worker-runtime` or smaller core package can reduce each venv package's base dependency closure when that package split is available. + +Alternatives considered: + +- Root `pyproject` extras as the only environment definitions: rejected because extras are additive fragments solved together in one environment, not isolated named outputs. +- Many in-code environment definition objects: rejected as too much central config surface compared with package-local `pyproject.toml` ownership. + +### Require import-safe module files + +A venv-deployable Module class must be importable by the coordinator environment. Heavy worker-only dependencies must not be imported at module import time. They should be imported inside runtime methods or helper paths reached only inside the worker environment. + +This lets the coordinator inspect type hints, streams, refs, config, and RPC metadata without installing the worker-only dependency closure. + +### Keep native module lifecycle in NativeModule + +Runtime environment unification must not move native subprocess lifecycle into the registry. `NativeModule` remains responsible for topic collection, command construction, `subprocess.Popen`, log watching, and shutdown. Runtime environments only resolve launch material such as executable, cwd, env, and optional build/prepare command. + +Legacy native config fields such as `executable`, `build_command`, `cwd`, and `extra_env` should remain supported during migration and may override or complement a named runtime environment. + +## Risks / Trade-offs + +- Pickle protocol compatibility across venvs → Mitigation: require compatible DimOS/source versions in phase 1 and add explicit version/import checks before deploying to a venv worker. +- Coordinator import-safety violations → Mitigation: document the rule and add tests that import venv-capable Module classes without worker-only dependencies installed. +- Root `dimos` package remains larger than ideal → Mitigation: allow phase-1 venv packages to depend on full `dimos`, then move to a smaller worker runtime package later. +- Runtime environment registry becomes too abstract → Mitigation: implement only the capabilities needed by venv worker launch and native executable resolution first. +- Native migration could break existing modules → Mitigation: keep current `NativeModuleConfig` fields and add runtime-env resolution as opt-in. +- Separate venv creation may be slow or brittle → Mitigation: phase 1 may bind to an already-created interpreter; package installation/creation automation can be added after the worker path is proven. + +## Migration Plan + +1. Add worker launch/process-handle abstractions without changing default forkserver behavior. +2. Implement venv worker launch behind an opt-in placement path. +3. Add typed runtime environment registry and Python venv backend. +4. Add demo package and blueprint showing dependency isolation. +5. Add native runtime environment resolution as opt-in while preserving existing native config fields. +6. Document package and import-safety conventions for venv modules. + +Rollback is straightforward for phase 1: remove venv placements and run modules in the default worker pool, or keep using existing native config fields instead of named runtime environments. + +## Open Questions + +- Should phase 1 keep pickled module class deployment only, or add explicit import descriptors in the initial implementation for clearer diagnostics? +- What exact API name should represent blueprint placement: `placements`, `runtime_placements`, or a more DimOS-specific method? +- Should venv environment creation be part of phase 1, or should phase 1 require pre-created venvs and only verify them? +- What is the first real non-demo module package to migrate after the lightweight demo proves the path? diff --git a/openspec/changes/venv-deployable-modules/proposal.md b/openspec/changes/venv-deployable-modules/proposal.md new file mode 100644 index 0000000000..ca42e05198 --- /dev/null +++ b/openspec/changes/venv-deployable-modules/proposal.md @@ -0,0 +1,33 @@ +## Why + +DimOS currently requires the coordinator environment to carry many module-specific runtime dependencies because Python workers are launched from the same environment as `dimos run`. This makes it difficult to deploy first-class DimOS Modules that need incompatible, optional, or hardware-specific Python dependency closures while keeping the main coordinator environment small and reliable. + +Native modules already have a declarative environment pattern through module-local `flake.nix` files. Python modules that need separate runtime dependencies need an analogous same-machine deployment path that preserves DimOS blueprint orchestration, worker lifecycle, streams, and RPCs. + +## What Changes + +- Add a Python-first runtime environment registry that names execution environments and resolves concrete local runtime material such as Python interpreters, native executables, environment variables, and optional preparation/build steps. +- Add blueprint-level placement for selected Python Modules into named runtime environments backed by separate Python virtual environments. +- Add a worker launch/process-handle abstraction so the existing Python worker protocol can run over either the current forkserver/Pipe path or a separately launched venv Python interpreter using `multiprocessing.connection.Listener/Client`. +- Define a packaging convention for venv-deployable Python Modules: each dependency-specialized module package owns its own `pyproject.toml` dependency closure, similar in role to a native module's `flake.nix`. +- Preserve existing DimOS Module semantics: modules remain first-class blueprint participants with normal streams, module refs, RPCs, lifecycle, and Pydantic config validation. +- Add a demo module package proving that the coordinator can build and wire a blueprint without installing a dependency that exists only in the venv worker environment. +- Provide a gradual path to unify current `NativeModuleConfig` executable/build fields with named runtime environments without removing the existing fields. + +## Capabilities + +### New Capabilities +- `runtime-environment-registry`: Defines named runtime environments and how DimOS-managed processes resolve interpreters, executables, environment variables, and preparation steps. +- `venv-module-placement`: Allows blueprints to place import-safe Python Modules into named Python venv worker pools while preserving normal DimOS worker protocol behavior. +- `venv-module-packaging`: Defines the package and import-safety convention for Python Modules whose runtime dependency closure lives outside the coordinator environment. + +### Modified Capabilities +- None. + +## Impact + +- Affected core areas: `dimos/core/coordination`, `dimos/core/module.py`, `dimos/core/native_module.py`, blueprint configuration APIs, and worker lifecycle tests. +- Adds a new local worker launch path using a separate Python executable and `multiprocessing.connection` for the control channel. +- Adds typed runtime environment configuration surfaced through Python APIs first; file loading may be added later as a convenience, not as the primary model. +- Adds packaging/documentation conventions under `packages/` for venv-deployable Python module packages. +- Keeps phase-1 behavior same-machine only; true remote deployment, cluster scheduling, and transport changes remain out of scope. diff --git a/openspec/changes/venv-deployable-modules/specs/runtime-environment-registry/spec.md b/openspec/changes/venv-deployable-modules/specs/runtime-environment-registry/spec.md new file mode 100644 index 0000000000..c0f3c9e320 --- /dev/null +++ b/openspec/changes/venv-deployable-modules/specs/runtime-environment-registry/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Runtime environments are named and Python-configurable +The system SHALL provide a Python API for registering named runtime environments that DimOS-managed processes can reference at blueprint build and module runtime. + +#### Scenario: Register runtime environments through Python API +- **WHEN** a blueprint or runtime configuration registers named runtime environments using typed Python objects +- **THEN** the coordinator resolves those environment names without requiring a YAML or TOML configuration file + +#### Scenario: Missing runtime environment name fails clearly +- **WHEN** a Module placement or NativeModule config references a runtime environment name that is not registered +- **THEN** blueprint build or module build fails with an error identifying the missing runtime environment name + +### Requirement: Runtime environments expose capability-specific resolution +The system SHALL let runtime environment consumers request only the runtime capability they need, such as Python interpreter resolution for worker launch or native executable resolution for `NativeModule` launch. + +#### Scenario: Python venv worker requests Python launch material +- **WHEN** a venv worker placement references a Python venv runtime environment +- **THEN** the worker launcher receives a Python executable path and environment variables needed to start the worker process + +#### Scenario: Native module requests native executable material +- **WHEN** a NativeModule references a Nix-backed runtime environment +- **THEN** the NativeModule receives executable, cwd, environment variables, and optional preparation/build material without transferring native process lifecycle ownership to the registry + +### Requirement: Runtime environments support current and Nix-backed execution material +The system SHALL support at least a current-process environment backend and a Nix-backed environment backend sufficient to model existing native module build/executable configuration. + +#### Scenario: Current environment backend is used +- **WHEN** a Module or process uses the current runtime environment +- **THEN** DimOS launches it with the coordinator's current Python/runtime environment semantics + +#### Scenario: Nix-backed environment resolves existing native configuration +- **WHEN** a Nix-backed runtime environment names a flake/package/executable equivalent to an existing native module `build_command` and `executable` +- **THEN** NativeModule can launch the same native process behavior through named environment resolution + +### Requirement: NativeModule legacy configuration remains supported +The system SHALL keep existing `NativeModuleConfig` executable, build command, cwd, and extra environment fields usable while adding named runtime environment resolution. + +#### Scenario: Legacy native configuration continues to work +- **WHEN** a NativeModule is configured only with existing executable/build/cwd/extra_env fields +- **THEN** it builds and starts using the same behavior as before runtime environment registry support + +#### Scenario: Runtime environment and legacy overrides are combined deterministically +- **WHEN** a NativeModule references a runtime environment and also supplies supported legacy override fields +- **THEN** DimOS applies a documented precedence order and launches with the resulting executable, cwd, env, and build behavior diff --git a/openspec/changes/venv-deployable-modules/specs/venv-module-packaging/spec.md b/openspec/changes/venv-deployable-modules/specs/venv-module-packaging/spec.md new file mode 100644 index 0000000000..27bd4440c2 --- /dev/null +++ b/openspec/changes/venv-deployable-modules/specs/venv-module-packaging/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Venv-deployable Modules are import-safe in coordinator environments +The system SHALL define venv-deployable Python Modules as import-safe Module classes whose defining files can be imported by the coordinator environment without importing worker-only optional dependencies at module import time. + +#### Scenario: Coordinator imports Module class without worker-only dependency +- **WHEN** the coordinator imports a venv-deployable Module class for blueprint wiring +- **THEN** the import succeeds even if the coordinator environment does not have the Module's worker-only runtime dependency installed + +#### Scenario: Worker-only dependency is imported at runtime +- **WHEN** the Module starts inside its assigned venv worker environment +- **THEN** it may import and use dependencies declared by its venv module package + +### Requirement: Venv Module packages declare dependency closure with pyproject +The system SHALL support separately packaged Python distributions for venv-deployable Modules where each package's `pyproject.toml` declares the dependency closure required by those Modules. + +#### Scenario: Venv module package installs into isolated venv +- **WHEN** a named Python runtime environment is prepared for a venv Module package +- **THEN** that environment installs the package according to its own `pyproject.toml` dependency declaration + +#### Scenario: Package dependency conflicts are isolated per venv package +- **WHEN** two venv Module packages require incompatible Python dependencies +- **THEN** each package can be installed in a separate named Python runtime environment without forcing those dependencies to resolve together in the coordinator environment + +### Requirement: Venv Module packages may depend on current dimos in phase 1 +The system SHALL allow phase-1 venv Module packages to depend on the current root `dimos` package while preserving a path to depend on a smaller worker runtime package later. + +#### Scenario: Phase-1 package depends on root dimos +- **WHEN** a demo or early venv Module package declares a dependency on the current `dimos` package plus module-specific dependencies +- **THEN** DimOS can launch the package in an isolated venv worker environment without requiring a prior core package split + +#### Scenario: Future package depends on worker runtime subset +- **WHEN** a smaller DimOS worker runtime package becomes available +- **THEN** venv Module packages can depend on that runtime package instead of the full root `dimos` package + +### Requirement: Demo proves dependency isolation with a lightweight package +The system SHALL include a demo package and blueprint proving that a Module-specific dependency can exist only in the venv worker environment while the coordinator still imports, builds, and wires the blueprint. + +#### Scenario: Coordinator lacks demo runtime dependency +- **WHEN** the coordinator environment does not have the demo package's worker-only dependency installed +- **THEN** the coordinator can still import the demo Module class and build the demo blueprint + +#### Scenario: Venv worker uses demo runtime dependency +- **WHEN** the demo blueprint runs with the demo Module placed into its named Python runtime environment +- **THEN** the Module uses the worker-only dependency inside the venv and publishes or responds through normal DimOS Module behavior diff --git a/openspec/changes/venv-deployable-modules/specs/venv-module-placement/spec.md b/openspec/changes/venv-deployable-modules/specs/venv-module-placement/spec.md new file mode 100644 index 0000000000..6a05219f39 --- /dev/null +++ b/openspec/changes/venv-deployable-modules/specs/venv-module-placement/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Blueprints place Modules into named Python runtime environments +The system SHALL allow blueprints to place selected import-safe Python Module classes into named Python runtime environments without making the placement an intrinsic Module class property. + +#### Scenario: Blueprint places one Module into a venv environment +- **WHEN** a blueprint places `CameraModule` into named runtime environment `sensors` +- **THEN** the coordinator deploys that Module to a worker process launched from the `sensors` Python environment + +#### Scenario: Same Module can run without venv placement +- **WHEN** the same Module class appears in a blueprint without venv placement +- **THEN** the coordinator deploys it through the default Python worker pool + +### Requirement: Venv workers preserve DimOS worker protocol semantics +The system SHALL preserve normal DimOS worker request/response semantics for Modules deployed into venv worker processes. + +#### Scenario: Venv Module receives lifecycle RPCs +- **WHEN** the coordinator builds, starts, stops, or undeploys a Module placed into a venv worker +- **THEN** the Module receives the same lifecycle calls it would receive in the default Python worker pool + +#### Scenario: Venv Module participates in stream wiring +- **WHEN** a Module placed into a venv worker has typed input or output streams +- **THEN** the coordinator wires those streams using the normal DimOS stream transport mechanism + +#### Scenario: Venv Module participates in Module refs and RPC calls +- **WHEN** another Module calls an RPC or uses a ref exposed by a Module placed into a venv worker +- **THEN** the call uses the normal DimOS proxy behavior and returns or fails according to the existing worker protocol semantics + +### Requirement: Venv workers use local multiprocessing connection control channel +The system SHALL launch venv worker processes as separate Python interpreters and use Python `multiprocessing.connection.Listener/Client` for the same-machine worker control channel. + +#### Scenario: Venv worker connects to coordinator control channel +- **WHEN** the coordinator launches a venv worker process +- **THEN** the worker connects to the coordinator's local multiprocessing connection endpoint before receiving deploy requests + +#### Scenario: Worker logs do not corrupt control messages +- **WHEN** a venv worker or hosted Module writes to stdout or stderr +- **THEN** those logs do not corrupt the worker request/response control channel + +### Requirement: Named venv worker pools isolate Python environments +The system SHALL maintain separate worker pools for distinct named Python runtime environments and SHALL NOT mix Modules assigned to different Python environments in the same worker process. + +#### Scenario: Multiple Modules share one named venv pool +- **WHEN** two Modules are placed into the same named Python runtime environment and capacity allows sharing +- **THEN** they may be hosted by the same venv worker pool according to existing worker capacity rules + +#### Scenario: Modules in different named venvs are isolated +- **WHEN** two Modules are placed into different named Python runtime environments +- **THEN** they run in separate worker pools launched from their respective Python interpreters + +### Requirement: Venv deployment fails during normal build lifecycle on incompatible environments +The system SHALL fail the normal blueprint build/deploy lifecycle when a named venv environment is missing, incompatible, or unable to import required DimOS worker runtime code. + +#### Scenario: Missing venv Python fails build +- **WHEN** a placement references a Python runtime environment whose configured Python executable does not exist +- **THEN** blueprint build fails with an actionable error identifying the runtime environment and missing executable + +#### Scenario: Worker runtime import failure fails build +- **WHEN** a venv worker starts but cannot import compatible DimOS worker runtime modules +- **THEN** deployment fails instead of silently degrading into a partial blueprint diff --git a/openspec/changes/venv-deployable-modules/tasks.md b/openspec/changes/venv-deployable-modules/tasks.md new file mode 100644 index 0000000000..bd40eca181 --- /dev/null +++ b/openspec/changes/venv-deployable-modules/tasks.md @@ -0,0 +1,52 @@ +## 1. Worker Launch Abstraction + +- [ ] 1.1 Extract the current forkserver process/Pipe operations into a worker process handle abstraction without changing default worker behavior. +- [ ] 1.2 Add a worker launcher abstraction with a forkserver launcher implementation that preserves current `PythonWorker` deployment tests. +- [ ] 1.3 Update worker manager code to use launcher/process-handle interfaces while keeping existing scheduling, capacity, dedicated-worker, and deploy_parallel behavior. +- [ ] 1.4 Add unit tests proving the default forkserver worker path still deploys, starts, calls RPCs, and shuts down Modules as before. + +## 2. Venv Worker Control Channel + +- [ ] 2.1 Add a worker entrypoint that can be launched with an arbitrary Python executable and connect back to the coordinator using `multiprocessing.connection.Client`. +- [ ] 2.2 Add a coordinator-side `multiprocessing.connection.Listener` setup for venv worker launch and connection acceptance. +- [ ] 2.3 Implement a venv worker process handle that sends and receives existing worker request/response objects over the multiprocessing connection channel. +- [ ] 2.4 Ensure worker stdout/stderr are handled separately from the control channel so logs cannot corrupt worker messages. +- [ ] 2.5 Add failure tests for missing Python executable, worker connection timeout, incompatible worker import, and worker startup error propagation. + +## 3. Runtime Environment Registry + +- [ ] 3.1 Define typed runtime environment models for current process, Python venv, and Nix-backed native executable resolution. +- [ ] 3.2 Add a Python-first runtime environment registry that resolves named environments and reports clear errors for unknown names or unsupported capabilities. +- [ ] 3.3 Wire runtime environment registry into blueprint/global runtime configuration without requiring YAML or TOML files. +- [ ] 3.4 Add tests for registering environments, resolving Python interpreter material, resolving native executable material, and missing-name diagnostics. + +## 4. Blueprint Venv Placement + +- [ ] 4.1 Add a blueprint-level placement API for assigning Module classes to named Python runtime environments. +- [ ] 4.2 Route placed Modules to named venv worker pools while unplaced Modules continue using the default worker pool. +- [ ] 4.3 Preserve same-env worker sharing and prevent cross-env Module mixing within one worker process. +- [ ] 4.4 Add integration tests with two Modules in one named venv pool and two Modules in distinct named venv pools. +- [ ] 4.5 Verify stream wiring, Module refs, and RPC calls work for Modules placed in venv worker pools. + +## 5. Native Module Runtime Environment Opt-In + +- [ ] 5.1 Extend `NativeModuleConfig` with optional runtime environment reference while preserving existing executable/build_command/cwd/extra_env fields. +- [ ] 5.2 Define and test deterministic precedence when a native runtime environment and legacy native config fields are both provided. +- [ ] 5.3 Update one Nix-backed native module test fixture or fake native module to resolve executable/build/env through a named runtime environment. +- [ ] 5.4 Confirm existing NativeModule tests and existing Nix-backed native module configs continue to work unchanged. + +## 6. Venv Module Packaging Convention and Demo + +- [ ] 6.1 Add a small separately packaged demo venv Module with its own `pyproject.toml` and a lightweight worker-only dependency not required by the coordinator environment. +- [ ] 6.2 Make the demo Module import-safe by avoiding worker-only dependency imports at module import time. +- [ ] 6.3 Add a demo blueprint that places the demo publisher Module into a named Python venv runtime environment and keeps a consumer Module in the default environment. +- [ ] 6.4 Add demo verification showing the coordinator can import/build the blueprint without the worker-only dependency installed. +- [ ] 6.5 Add runtime demo verification showing the venv worker imports the worker-only dependency and communicates through normal DimOS streams or RPCs. + +## 7. Documentation and Validation + +- [ ] 7.1 Document import-safe module file rules and the separately packaged venv Module convention. +- [ ] 7.2 Document runtime environment registry usage for Python venv workers and Nix-backed native modules. +- [ ] 7.3 Document phase-1 limitations: same-machine only, compatible DimOS/source versions required, and no remote deployment agent yet. +- [ ] 7.4 Run focused worker, blueprint, native module, and demo tests. +- [ ] 7.5 Run broader relevant test suite or document any skipped slow/hardware-dependent tests. From b7ed8176f22350df15e9a8ef723bf8e9e6c6b955 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 18:07:40 -0700 Subject: [PATCH 085/110] spec: agentic manipulation skills --- CONTEXT.md | 24 +++++ .../.openspec.yaml | 2 + .../design.md | 94 +++++++++++++++++++ .../proposal.md | 28 ++++++ .../agentic-manipulation-primitives/spec.md | 49 ++++++++++ .../tasks.md | 30 ++++++ 6 files changed, 227 insertions(+) create mode 100644 openspec/changes/add-agentic-manipulation-primitives/.openspec.yaml create mode 100644 openspec/changes/add-agentic-manipulation-primitives/design.md create mode 100644 openspec/changes/add-agentic-manipulation-primitives/proposal.md create mode 100644 openspec/changes/add-agentic-manipulation-primitives/specs/agentic-manipulation-primitives/spec.md create mode 100644 openspec/changes/add-agentic-manipulation-primitives/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md index 75ff5b250b..05bfd0f6be 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,22 @@ _Avoid_: combined URDF, merged robot scene The authoritative belief state for manipulation planning, including robot state and scene state used by planners. _Avoid_: planner context, backend instance +**Robokin kinematics backend**: +A DimOS kinematics backend that presents multiple robokin-supported inverse-kinematics engines through one robotics-facing capability. +_Avoid_: Oink backend, RoboKin world backend, single-engine Oink solver + +**Robokin engine**: +A specific solver implementation selected inside the Robokin kinematics backend, such as Placo, Pyroki, or Oink. +_Avoid_: Robokin backend, world backend, planner backend + +**RoboPlan kinematics backend**: +A DimOS kinematics backend implemented by RoboPlanWorld using RoboPlan-backed model state, planning groups, Jacobians, and collision state. +_Avoid_: Robokin backend, separate RoboPlan IK world, planner-only RoboPlan integration + +**RoboPlan Oink IK solver**: +RoboPlan's task-based inverse-kinematics capability for solving one or more frame pose targets under joint constraints. +_Avoid_: hand-written Jacobian IK, Robokin-only Oink wrapper + **Coordinated simulation clock**: A simulation benchmark clock policy where simulator time advances in lockstep with the DimOS control coordinator clock. _Avoid_: autonomous simulator loop, write-triggered stepping, settle step @@ -140,6 +156,14 @@ _Avoid_: manipulator-only adapter, end-effector API, task action API A backend-facing declaration of benchmark intent that names the task, robot, runtime constraints, and evaluation setup before any DimOS blueprint is launched. _Avoid_: hardware config, simulator config, blueprint config +**Semantic skill benchmark episode**: +A benchmark episode where the agent acts through named, task-level DimOS skills while simulator backends provide reset, observation, and external scoring. +_Avoid_: motor-control benchmark episode, raw simulator action episode, code-as-policy episode + +**Agentic manipulation module**: +A universal DimOS skill-facing module that exposes agent-appropriate manipulation capabilities by coordinating existing manipulation and control modules. +_Avoid_: benchmark skill container, Robosuite skill module, simulator-specific manipulation API + **Resolved runtime plan**: The concrete DimOS launch material derived from a benchmark episode config, including hardware components, simulator connection config, observation streams, evaluator setup, and artifact routing. _Avoid_: benchmark intent, user-authored task config diff --git a/openspec/changes/add-agentic-manipulation-primitives/.openspec.yaml b/openspec/changes/add-agentic-manipulation-primitives/.openspec.yaml new file mode 100644 index 0000000000..f9be753a1e --- /dev/null +++ b/openspec/changes/add-agentic-manipulation-primitives/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-27 diff --git a/openspec/changes/add-agentic-manipulation-primitives/design.md b/openspec/changes/add-agentic-manipulation-primitives/design.md new file mode 100644 index 0000000000..f74c884e64 --- /dev/null +++ b/openspec/changes/add-agentic-manipulation-primitives/design.md @@ -0,0 +1,94 @@ +## Context + +DimOS already has a `ManipulationModule` that integrates robot model configuration, planning, coordinator dispatch, joint state monitoring, and primitive skills such as robot state queries, joint motion, and gripper commands. The existing `PickAndPlaceModule` extends that lower-level stack with perception/object semantics, but the next agentic manipulation step needs a smaller universal primitive surface that can be exercised before committing to higher-level pick/place APIs. + +Robosuite integration currently validates runtime plumbing through a script-hosted sidecar demo. That demo starts Robosuite in a subprocess, resolves a runtime plan, creates a SHM motor bridge, instantiates a `ControlCoordinator`, and drives a servo task directly. It does not build the manipulation planning stack or call a skill-facing module API. The new validation should keep the same script-hosted runtime boundary while adding the full manipulation stack above the hardware adapter. + +## Goals / Non-Goals + +**Goals:** + +- Introduce a universal `AgenticManipulationModule` in `dimos/manipulation/` that exposes a small, agent-appropriate primitive skill surface. +- Implement the module as a facade over existing manipulation RPCs using the DimOS Spec injection pattern. +- Keep the initial primitive surface limited to joint-space and gripper operations: robot state, move to joints, open gripper, and close gripper. +- Unit-test the facade without Robosuite, benchmark runtime clients, sidecars, or heavy simulator dependencies. +- Add a script-hosted Robosuite layer-2 demo that constructs the full stack dynamically and calls the new API through DimOS modules. + +**Non-Goals:** + +- Running an LLM agent or creating a final MCP tool profile. +- Filtering MCP skill exposure when both `ManipulationModule` and `AgenticManipulationModule` are present. +- Making Robosuite or runtime sidecar APIs part of the universal module. +- Requiring Cartesian planning or relative end-effector motion in this slice. +- Promoting the Robosuite validation script to a product CLI or registered blueprint. + +## Decisions + +### Agentic facade over subclassing + +Create `AgenticManipulationModule` as a `Module` that depends on a `ManipulationControlSpec` Protocol instead of subclassing `ManipulationModule`. + +Rationale: the agent-facing module should remain a stable facade over whatever manipulation/control stack is present. Subclassing would couple the new surface to `ManipulationModule` internals and make it harder to later swap or compose other manipulation implementations. Spec injection also matches DimOS blueprint wiring and fails at build time if a compatible manipulation provider is absent. + +Alternative considered: subclass `ManipulationModule` and redecorate selected methods. This is simpler initially but exposes internals, duplicates existing skill registration concerns, and makes it harder to keep the universal surface independent of planner implementation details. + +### Thin primitive wrapper first + +The first module methods should delegate to existing manipulation RPCs and return their `SkillResult` values rather than inventing a new result model. + +Rationale: this preserves existing error semantics and avoids premature API design before Robosuite and real robot experiments show which higher-level skills are appropriate. The facade can still improve docstrings and names for agent use. + +Alternative considered: define new structured result objects for every primitive. This could be useful later, but it increases integration work before the primitive behavior is validated. + +### Unit tests stay simulator-free + +Unit tests should inject a fake manipulation provider and verify delegation, signatures, docstrings, and result passthrough. + +Rationale: Robosuite is a heavy optional dependency and should not be required by default `pytest`. The universal module has no simulator-specific behavior, so simulator-free tests are the right layer for correctness. + +Alternative considered: include a Robosuite smoke test in pytest behind a marker immediately. This adds maintenance and environment cost before the API shape is proven. + +### Robosuite validation remains script-hosted + +Add `scripts/benchmarks/demo_agentic_manipulation_robosuite.py` rather than a registered blueprint or product CLI. + +Rationale: the demo needs a pre-blueprint construction stage: start the sidecar, describe/reset the episode, resolve the motor surface, build a dynamic `RobotConfig`, derive blueprint inputs, then build the DimOS stack. The existing script-hosted runtime demo already establishes this orchestration shape. + +Alternative considered: add a static blueprint and a separate RPC client. Static blueprint registration cannot know the runtime motor surface and SHM address before sidecar startup. + +### Dynamic RobotConfig bridge + +The Robosuite demo should convert resolved runtime information into a `RobotConfig`, then use `RobotConfig` methods to derive `HardwareComponent`, `TaskConfig`, and `RobotModelConfig`. + +Rationale: `RobotConfig` is the existing pre-blueprint robot configuration abstraction that spans hardware, coordinator task, and manipulation planning model construction. Generating only `RobotModelConfig` would leave the coordinator and hardware adapter configured through a separate path. + +Alternative considered: directly instantiate all three derived objects in the demo. That is acceptable as a fallback for missing fields, but the intended implementation should centralize derivation through `RobotConfig` wherever current APIs allow. + +### Background runtime stepping for blocking API calls + +The Robosuite demo should run the SHM-to-sidecar stepping loop in a background thread while the foreground calls `AgenticManipulationModule` APIs. + +Rationale: motion APIs may block while planning, dispatching, and waiting for coordinator completion. The runtime sidecar still needs regular steps to convert coordinator commands into simulator state and write motor state back into SHM. + +Alternative considered: interleave one API call with manual step batches in the foreground. That does not match blocking skill execution and risks deadlock when a motion call waits for state progression. + +## Risks / Trade-offs + +- [Risk] The current Robosuite Panda Lift profile may not provide all model metadata needed to construct a robust `RobotConfig`. → Mitigation: keep direct object construction as a narrow script-local fallback and document missing model assumptions in the demo artifact summary. +- [Risk] Existing `ManipulationModule.move_to_joints` plans and executes through a trajectory path, while the current Robosuite demo uses a servo task. → Mitigation: configure a trajectory-compatible coordinator task for the layer-2 demo instead of reusing the direct servo-only loop. +- [Risk] Both old and new manipulation skills would be visible if MCP is added to the same stack. → Mitigation: keep MCP/agent execution out of this change; handle skill filtering in a later change. +- [Risk] Robosuite validation may be unavailable in default developer environments. → Mitigation: keep it manual/script-only and fail with a clear message when the sidecar cannot start. +- [Risk] The facade may be too thin to add much value initially. → Mitigation: treat this as a stable primitive seam for experiments; evolve higher-level semantics only after Robosuite and real stack validation. + +## Migration Plan + +1. Add the universal module and tests without changing existing manipulation module behavior. +2. Add the Robosuite demo as an opt-in script that does not affect default test runs or blueprint registry generation. +3. Document the demo command and scope boundaries. +4. Rollback is removal of the new module, tests, docs, and script; no existing API behavior changes are required. + +## Open Questions + +- Which higher-level semantic manipulation skills should be exposed after joint/gripper primitives are validated? +- What MCP filtering mechanism should hide lower-level provider skills when an agent-facing facade is present? +- What additional runtime metadata should Robosuite sidecars expose to make `RobotConfig` derivation less demo-specific? diff --git a/openspec/changes/add-agentic-manipulation-primitives/proposal.md b/openspec/changes/add-agentic-manipulation-primitives/proposal.md new file mode 100644 index 0000000000..6c44146885 --- /dev/null +++ b/openspec/changes/add-agentic-manipulation-primitives/proposal.md @@ -0,0 +1,28 @@ +## Why + +DimOS has manipulation planning and control primitives, but the current skill-facing surface is either too low-level for direct agent use or tied to existing pick-and-place semantics. We need a universal agentic manipulation module that exposes a small, testable primitive skill surface before building higher-level manipulation benchmark episodes or MCP tool profiles. + +## What Changes + +- Add a universal `AgenticManipulationModule` that wraps existing manipulation/control RPCs through the DimOS Spec injection pattern. +- Expose an initial joint-space and gripper primitive skill surface suitable for direct agent use: robot state, joint motion, open gripper, and close gripper. +- Add simulator-free unit tests for the facade behavior and skill surface. +- Add a script-hosted Robosuite layer-2 API demo that constructs the full stack dynamically and calls the new module API directly. +- Keep Robosuite, benchmark runtime clients, MCP filtering, LLM agent execution, and Cartesian planning outside the universal module scope. + +## Capabilities + +### New Capabilities +- `agentic-manipulation-primitives`: Universal agent-facing manipulation primitive skills and a script-hosted Robosuite API validation path. + +### Modified Capabilities + +## Impact + +- Affected code areas: + - `dimos/manipulation/` for the new module, RPC Spec, and simulator-free tests. + - `scripts/benchmarks/` for the Robosuite layer-2 demo. + - runtime sidecar/manipulation docs for the manual validation command and scope boundaries. +- The new module must not import Robosuite, runtime sidecar clients, or benchmark-specific APIs. +- The Robosuite validation remains script-only and manual/heavy; default unit tests remain lightweight. +- Future MCP/agent work can expose this module without changing its primitive behavior. diff --git a/openspec/changes/add-agentic-manipulation-primitives/specs/agentic-manipulation-primitives/spec.md b/openspec/changes/add-agentic-manipulation-primitives/specs/agentic-manipulation-primitives/spec.md new file mode 100644 index 0000000000..6bcccf328e --- /dev/null +++ b/openspec/changes/add-agentic-manipulation-primitives/specs/agentic-manipulation-primitives/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Universal agentic manipulation facade +The system SHALL provide an `AgenticManipulationModule` that exposes a universal agent-facing manipulation primitive surface through DimOS skills. + +#### Scenario: Facade delegates to manipulation provider +- **WHEN** a caller invokes a primitive skill on `AgenticManipulationModule` +- **THEN** the module MUST delegate the operation to an injected manipulation provider through a DimOS Spec/RPC contract + +#### Scenario: Facade remains simulator independent +- **WHEN** `AgenticManipulationModule` is imported or unit tested +- **THEN** the module MUST NOT require Robosuite, runtime sidecar clients, or benchmark-specific APIs + +### Requirement: Initial primitive skill surface +The system SHALL expose robot state, joint motion, open gripper, and close gripper as the initial agentic manipulation primitive skills. + +#### Scenario: Robot state primitive is available +- **WHEN** a caller invokes the robot state primitive +- **THEN** the system MUST return the injected manipulation provider's robot state result + +#### Scenario: Joint motion primitive is available +- **WHEN** a caller invokes the joint motion primitive with a target joint configuration +- **THEN** the system MUST forward the target to the injected manipulation provider's joint motion operation + +#### Scenario: Gripper primitives are available +- **WHEN** a caller invokes the open or close gripper primitive +- **THEN** the system MUST forward the command to the injected manipulation provider's gripper operation + +### Requirement: Simulator-free primitive tests +The system SHALL include default unit tests for the agentic manipulation facade that do not depend on Robosuite or other heavy simulator runtimes. + +#### Scenario: Default test execution +- **WHEN** the default Python test suite runs without Robosuite installed +- **THEN** the agentic manipulation primitive unit tests MUST be able to execute using a fake injected manipulation provider + +### Requirement: Script-hosted Robosuite API validation +The system SHALL provide a script-hosted Robosuite validation path that calls the `AgenticManipulationModule` API through the full DimOS manipulation stack. + +#### Scenario: Full stack validation +- **WHEN** the Robosuite validation script runs in an environment with the Robosuite sidecar dependencies available +- **THEN** it MUST construct a stack containing the Robosuite sidecar, benchmark runtime SHM adapter, `ControlCoordinator`, `ManipulationModule`, and `AgenticManipulationModule` + +#### Scenario: API smoke assertions +- **WHEN** the Robosuite validation script calls the agentic manipulation primitives +- **THEN** it MUST fail if robot state, joint motion, open gripper, or close gripper does not report success through the API path + +#### Scenario: Script-hosted artifacts +- **WHEN** the Robosuite validation script completes or fails +- **THEN** it MUST write artifacts describing the episode config, runtime description, resolved runtime plan, API call summary, motor trace, score when available, sidecar log, and cleanup status diff --git a/openspec/changes/add-agentic-manipulation-primitives/tasks.md b/openspec/changes/add-agentic-manipulation-primitives/tasks.md new file mode 100644 index 0000000000..5a9331851a --- /dev/null +++ b/openspec/changes/add-agentic-manipulation-primitives/tasks.md @@ -0,0 +1,30 @@ +## 1. Agentic Manipulation Module + +- [ ] 1.1 Add `dimos/manipulation/agentic_manipulation_spec.py` with a `ManipulationControlSpec` Protocol covering robot state, joint motion, open gripper, and close gripper. +- [ ] 1.2 Add `dimos/manipulation/agentic_manipulation_module.py` with `AgenticManipulationModule` as a `Module` facade using Spec-injected manipulation control. +- [ ] 1.3 Decorate the facade primitives with `@skill`, include required docstrings and type annotations, and return the delegated `SkillResult` values. +- [ ] 1.4 Ensure the new module has no imports from Robosuite, benchmark runtime clients, sidecar packages, or script-only benchmark code. + +## 2. Simulator-Free Tests + +- [ ] 2.1 Add default unit tests for `AgenticManipulationModule` using a fake injected manipulation provider. +- [ ] 2.2 Verify each primitive delegates the expected arguments to the fake provider and passes the provider result back to the caller. +- [ ] 2.3 Verify the skill methods have schema-safe signatures and docstrings suitable for MCP exposure. +- [ ] 2.4 Run the new unit tests without Robosuite dependencies. + +## 3. Robosuite Layer-2 Demo + +- [ ] 3.1 Add `scripts/benchmarks/demo_agentic_manipulation_robosuite.py` following the sidecar startup, health wait, describe/reset, artifact, and cleanup structure from `demo_robosuite_panda_lift.py`. +- [ ] 3.2 Build a dynamic pre-blueprint robot configuration from the resolved Robosuite runtime plan and derive the `HardwareComponent`, coordinator `TaskConfig`, and `RobotModelConfig` needed by the stack. +- [ ] 3.3 Construct the in-script blueprint with `ControlCoordinator`, `ManipulationModule`, and `AgenticManipulationModule`, then start it with `ModuleCoordinator.build(...)`. +- [ ] 3.4 Implement a background SHM-to-sidecar stepping loop so blocking manipulation API calls can progress simulator state. +- [ ] 3.5 Call the `AgenticManipulationModule` API directly and fail hard unless robot state, open gripper, close gripper, and a safe small-offset joint motion report success. +- [ ] 3.6 Write episode config, runtime description, resolved runtime plan, API call summary, motor trace, score when available, sidecar log, and cleanup status artifacts. + +## 4. Documentation and Validation + +- [ ] 4.1 Document the Robosuite demo command and clarify that it is manual/script-hosted layer-2 validation, not a default unit test. +- [ ] 4.2 Document that the universal module is simulator-independent and that MCP filtering, LLM agent execution, Cartesian motion, and higher-level semantic manipulation skills are future work. +- [ ] 4.3 Run `uv run pytest dimos/manipulation/test_agentic_manipulation_module.py -v`. +- [ ] 4.4 If Robosuite dependencies are available, run the new script-hosted Robosuite demo and inspect its artifacts. +- [ ] 4.5 Run OpenSpec validation for this change and fix any proposal, spec, design, or task formatting issues. From 91759ce3a98baea06665a8f483a434885151efe6 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 19:20:09 -0700 Subject: [PATCH 086/110] spec: roboplan ik --- .../roboplan-oink-kinematics/.openspec.yaml | 2 + .../roboplan-oink-kinematics/design.md | 112 ++++++++++++++++++ .../roboplan-oink-kinematics/proposal.md | 41 +++++++ .../specs/roboplan-oink-kinematics/spec.md | 87 ++++++++++++++ .../changes/roboplan-oink-kinematics/tasks.md | 36 ++++++ 5 files changed, 278 insertions(+) create mode 100644 openspec/changes/roboplan-oink-kinematics/.openspec.yaml create mode 100644 openspec/changes/roboplan-oink-kinematics/design.md create mode 100644 openspec/changes/roboplan-oink-kinematics/proposal.md create mode 100644 openspec/changes/roboplan-oink-kinematics/specs/roboplan-oink-kinematics/spec.md create mode 100644 openspec/changes/roboplan-oink-kinematics/tasks.md diff --git a/openspec/changes/roboplan-oink-kinematics/.openspec.yaml b/openspec/changes/roboplan-oink-kinematics/.openspec.yaml new file mode 100644 index 0000000000..578bd54974 --- /dev/null +++ b/openspec/changes/roboplan-oink-kinematics/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-26 diff --git a/openspec/changes/roboplan-oink-kinematics/design.md b/openspec/changes/roboplan-oink-kinematics/design.md new file mode 100644 index 0000000000..46eb83a7e5 --- /dev/null +++ b/openspec/changes/roboplan-oink-kinematics/design.md @@ -0,0 +1,112 @@ +## Context + +The manipulation planning factory currently supports world backends `drake` and `roboplan`, planners `rrt_connect` and `roboplan`, and kinematics backends `jacobian`, `drake_optimization`, and `pink`. RoboPlan planning is already scene-coupled: `create_planner(name="roboplan", world=..., world_backend="roboplan")` returns the existing `RoboPlanWorld` object as `PlannerSpec`. + +`RoboPlanWorld` owns the RoboPlan `Scene`, live/scratch contexts, global/native joint-name maps, planning-group registry, generated composite groups, group FK/Jacobian helpers, selected-state overlays, joint limits, and collision checks. Those are also the exact inputs needed for RoboPlan-native IK. + +Robokin's `RoboPlanOinkKinematics` wrapper demonstrates the relevant RoboPlan API shape: construct `Oink(scene, group_name)`, create `FrameTask` objects from `CartesianConfiguration`, add `PositionLimit` and optional `VelocityLimit` constraints, call `oink.solveIk(...)`, integrate `delta_q` through the scene, and repeat toward the goal. Local bindings expose `roboplan.optimal_ik.Oink`, `FrameTask`, `FrameTaskOptions`, `PositionLimit`, and `VelocityLimit`. + +## Goals / Non-Goals + +**Goals:** + +- Add `backend="roboplan"` as a typed manipulation kinematics config. +- Keep `RoboPlanWorld` as the single RoboPlan integration object for world, planner, and kinematics roles. +- Implement `KinematicsSpec` on `RoboPlanWorld` using RoboPlan Oink task IK rather than a hand-written Jacobian pseudoinverse loop. +- Support multiple pose targets when the selected planning groups are non-overlapping and represented by one RoboPlan group or generated composite group. +- Preserve DimOS public global joint names at API boundaries. +- Return selected global joints in the `IKResult`, including auxiliary selected groups that retain seed/current positions. +- Fail clearly for unsupported selections, missing pose target frames, missing Oink bindings, or collision-invalid final states. + +**Non-Goals:** + +- Do not implement the deferred robokin backend. +- Do not split `RoboPlanWorld` into `RoboPlanPlanner` or `RoboPlanIK` adapter classes. +- Do not add a planner config unless RoboPlan planner tunables are needed separately. +- Do not promise global IK completeness or obstacle-aware IK optimization. +- Do not change existing `planner_name="roboplan"` behavior beyond any shared helper reuse needed by IK. +- Do not change planning-group semantics to allow overlapping selected joints. + +## Decisions + +### Decision: Use one RoboPlanWorld object for all RoboPlan roles + +`RoboPlanWorld` should implement the existing world role, continue serving as `PlannerSpec` for `planner_name="roboplan"`, and also serve as `KinematicsSpec` for `kinematics.backend="roboplan"`. + +Rationale: RoboPlan IK needs the same scene, contexts, group registry, native order, and collision state that `RoboPlanWorld` already owns. A separate adapter would either call private internals or require an artificial service surface that adds indirection without reducing coupling. + +Alternative considered: create `RoboPlanIK(world, config)` and `RoboPlanPlanner(world)` adapters. Rejected because the user prefers the current single-class pattern, and the existing planner path already established `RoboPlanWorld` as the coupled integration object. + +### Decision: Configure role-specific options after world construction + +`RoboPlanWorld` remains constructed from world/backend options. `create_kinematics(config=RoboPlanKinematicsConfig, world=..., world_backend="roboplan")` should configure the existing world with Oink IK options and return it as `KinematicsSpec`. + +Suggested shape: + +```python +class RoboPlanKinematicsConfig(BaseConfig): + backend: Literal["roboplan"] = "roboplan" + max_iterations: int = 100 + dt: float = 0.05 + position_cost: float = 1.0 + orientation_cost: float = 1.0 + task_gain: float = 0.5 + lm_damping: float = 1e-6 + regularization: float = 1e-8 + velocity_limit: float | None = None + collision_check: bool = True +``` + +Rationale: world options and IK solver options have different lifecycles. Passing all role-specific tuning through the world constructor would make the constructor absorb every future planner/kinematics knob. Configuring the single object after construction preserves one integration object while keeping configs role-specific. + +Alternative considered: avoid a dedicated config class and rely on `kinematics_name="roboplan"` only. Rejected because Oink has real solver knobs that need typed defaults and future CLI/config override support. + +### Decision: Use RoboPlan Oink, not DimOS JacobianIK math + +RoboPlan IK should follow the Oink task-solver pattern: create one `FrameTask` per target, add joint constraints, call `solveIk`, integrate the delta, and iterate until tolerances are met or iteration budget is exhausted. + +Rationale: Oink is RoboPlan's task-based IK surface and supports multiple tasks in one solve call. It also aligns with robokin's RoboPlan wrapper while allowing DimOS to generalize from one target frame to multiple DimOS planning groups. + +Alternative considered: implement a local stacked-Jacobian damped least-squares loop with `get_group_jacobian(...)`. Rejected because it duplicates solver logic already available in RoboPlan and would likely handle task priorities, damping, and constraints less consistently than Oink. + +### Decision: Model multitarget IK as a selected planning-group problem + +`solve_pose_targets(...)` should build a `PlanningGroupSelection` from pose target groups plus auxiliary groups. Existing selection validation rejects overlapping selected joints. RoboPlan IK should resolve that selection to a RoboPlan group or generated composite group, then create one `FrameTask` for each pose target group. + +Rationale: this preserves the current DimOS rule that selected groups cannot overlap. It naturally supports multi-robot targets and same-robot disjoint targets through existing composite group generation, while avoiding ambiguous ownership of shared joints. + +Alternative considered: allow overlapping groups and solve with shared variables. Rejected for v1 because it changes planning-group semantics and would require new conflict-resolution rules for returned selected joints. + +### Decision: Collision validation, not collision-aware IK, in v1 + +RoboPlan IK should optionally check collision freedom of the final selected candidate using existing RoboPlanWorld collision helpers. If the converged candidate is colliding, return an IK failure. It should not add obstacle-avoidance tasks or try to optimize around collisions in this slice. + +Rationale: Oink task IK and path planning serve different purposes. The caller can plan to a valid IK result afterward. Adding collision avoidance inside IK increases scope and tuning complexity before basic RoboPlan-native IK is validated. + +Alternative considered: integrate collision avoidance constraints into Oink immediately. Rejected as broader and less certain than the current need. + +## Risks / Trade-offs + +- `roboplan.optimal_ik` pybind signatures are opaque. → Mitigation: mirror the call pattern validated by robokin and cover the binding surface with faked tests plus at least one import/constructor smoke path when dependencies are available. +- Multi-target IK depends on generated composite group availability. → Mitigation: reuse existing selection-to-group validation and fail with `IKStatus.NO_SOLUTION` when a selection cannot be represented by RoboPlan. +- Oink IK may converge locally but not globally. → Mitigation: document local IK semantics and keep path planning as the follow-up for moving to the IK result. +- Collision-free final states may still be unreachable by a path planner. → Mitigation: treat IK as endpoint generation only; do not weaken planner validation. +- Returning the same object for world, planner, and kinematics increases class responsibility. → Mitigation: keep implementation helpers private and cohesive around RoboPlan scene/group state; revisit splitting only if the class becomes unmanageable. + +## Migration Plan + +1. Add `RoboPlanKinematicsConfig` and include it in the discriminated `ManipulationKinematicsConfig` union and legacy name lookup. +2. Extend supported kinematics and backend-combination validation with `roboplan` requiring `world_backend="roboplan"`. +3. Change `create_kinematics(...)` and `create_planning_specs(...)` so RoboPlan kinematics receives and returns the existing `RoboPlanWorld` object. +4. Add RoboPlanWorld kinematics configuration storage with default values. +5. Implement `solve(...)` as a one-target convenience wrapper over `solve_pose_targets(...)` where compatible with group semantics. +6. Implement `solve_pose_targets(...)` with Oink tasks, selected/composite group resolution, seed/current-state initialization, iterative integration, tolerance checks, optional final collision validation, and selected global joint-state return. +7. Add factory/config tests and RoboPlanWorld unit tests using fakes for `roboplan.optimal_ik`. +8. Run targeted manipulation planning tests and OpenSpec validation. + +Rollback is isolated to the kinematics config/factory additions and `RoboPlanWorld` `KinematicsSpec` methods. Existing RoboPlan planner behavior should remain intact. + +## Open Questions + +- Should Oink `FrameTaskOptions.priority` be exposed in config immediately, or kept at binding/default behavior until a concrete need appears? +- Should velocity limits be configured as one scalar, per-selected-joint values, or initially omitted unless the binding requires them? diff --git a/openspec/changes/roboplan-oink-kinematics/proposal.md b/openspec/changes/roboplan-oink-kinematics/proposal.md new file mode 100644 index 0000000000..32e564cf20 --- /dev/null +++ b/openspec/changes/roboplan-oink-kinematics/proposal.md @@ -0,0 +1,41 @@ +## Why + +`RoboPlanWorld` is already the RoboPlan-backed planning world and native planner for DimOS manipulation, but inverse kinematics still routes through separate backends such as Pink or generic Jacobian IK. That forces RoboPlan stacks to cross model/context boundaries for IK even though `RoboPlanWorld` already owns the RoboPlan scene, planning-group metadata, current belief state, joint-limit ordering, collision checks, and group FK/Jacobian queries. + +RoboPlan exposes `roboplan.optimal_ik` bindings locally, including the Oink task solver used by robokin's RoboPlan wrapper. DimOS can use those bindings directly inside `RoboPlanWorld` to provide a RoboPlan-native `KinematicsSpec` without adding a separate adapter object or reviving the deferred robokin backend. + +## What Changes + +- Add `backend="roboplan"` to manipulation kinematics configuration with Oink solver tunables. +- Extend factory validation so `kinematics_name="roboplan"` requires `world_backend="roboplan"`. +- Make `create_kinematics(...)` return the existing `RoboPlanWorld` instance as `KinematicsSpec` for RoboPlan kinematics, matching the current `PlannerSpec` pattern. +- Add `KinematicsSpec.solve(...)` and `solve_pose_targets(...)` behavior to `RoboPlanWorld` using `roboplan.optimal_ik.Oink` with one `FrameTask` per pose target. +- Support multi-target IK for non-overlapping selected planning groups, including multi-robot selections and same-robot disjoint groups represented by generated RoboPlan composite groups. +- Keep IK collision behavior narrow: verify final candidate collision freedom, but do not implement obstacle-aware IK optimization in this slice. +- Keep the deferred robokin backend out of scope. + +## Capabilities + +### New Capabilities + +- `roboplan-oink-kinematics`: RoboPlanWorld provides RoboPlan Oink-backed inverse kinematics through the existing DimOS `KinematicsSpec` surface. + +### Modified Capabilities + +- `roboplan-composite-multi-robot-planning`: RoboPlan composite group metadata is reused by IK target selections; no planner behavior change is required. + +## Impact + +- Affected code areas: + - `dimos/manipulation/planning/kinematics/config.py` + - `dimos/manipulation/planning/factory.py` + - `dimos/manipulation/planning/world/roboplan_world.py` + - `dimos/manipulation/test_planning_factory.py` + - `dimos/manipulation/test_roboplan_world.py` +- Public configuration impact: + - `kinematics_name="roboplan"` and/or `kinematics.backend="roboplan"` become valid only with `world_backend="roboplan"`. +- Dependency impact: + - No new dependency is introduced; the change uses existing `roboplan.optimal_ik` bindings when RoboPlan is installed. +- Behavior impact: + - RoboPlan planning remains wired as `planner_name="roboplan" -> RoboPlanWorld`. + - RoboPlan IK uses the same single `RoboPlanWorld` object rather than a separate adapter. diff --git a/openspec/changes/roboplan-oink-kinematics/specs/roboplan-oink-kinematics/spec.md b/openspec/changes/roboplan-oink-kinematics/specs/roboplan-oink-kinematics/spec.md new file mode 100644 index 0000000000..74503ec656 --- /dev/null +++ b/openspec/changes/roboplan-oink-kinematics/specs/roboplan-oink-kinematics/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: RoboPlan kinematics configuration +The manipulation kinematics configuration SHALL support a `roboplan` backend with typed Oink solver tuning fields. + +#### Scenario: Default RoboPlan kinematics config +- **WHEN** a caller requests kinematics config from the legacy name `roboplan` +- **THEN** the system returns a config whose discriminator is `backend="roboplan"` and whose Oink solver fields have deterministic defaults + +#### Scenario: RoboPlan kinematics requires RoboPlan world +- **WHEN** backend validation receives `kinematics_name="roboplan"` with any `world_backend` other than `roboplan` +- **THEN** validation fails with a clear error that RoboPlan kinematics requires `world_backend="roboplan"` + +### Requirement: RoboPlanWorld acts as KinematicsSpec +When RoboPlan kinematics is selected, the planning factory SHALL return the existing `RoboPlanWorld` instance as the `KinematicsSpec`. + +#### Scenario: Factory returns shared RoboPlanWorld for kinematics +- **WHEN** `create_planning_specs` is called with `world_backend="roboplan"` and `kinematics.backend="roboplan"` +- **THEN** the returned kinematics object is the same `RoboPlanWorld` instance passed to the factory + +#### Scenario: Planner wiring remains unchanged +- **WHEN** `planner_name="roboplan"` is selected with `world_backend="roboplan"` +- **THEN** the returned planner object remains the same `RoboPlanWorld` instance as before + +### Requirement: Oink-backed pose target solving +`RoboPlanWorld.solve_pose_targets` SHALL solve pose targets using RoboPlan Oink tasks and constraints instead of a hand-written DimOS Jacobian pseudoinverse loop. + +#### Scenario: Single pose target creates one frame task +- **WHEN** one pose-targeted planning group is provided +- **THEN** RoboPlan IK creates one Oink frame task for that group's target frame and solves through `Oink.solveIk` + +#### Scenario: Multiple pose targets create multiple frame tasks +- **WHEN** multiple non-overlapping pose-targeted planning groups are provided and their selection maps to a RoboPlan group or generated composite group +- **THEN** RoboPlan IK creates one Oink frame task per pose target and solves them in one selected-group IK problem + +#### Scenario: Missing Oink binding +- **WHEN** the RoboPlan optimal IK binding is unavailable +- **THEN** RoboPlan IK returns or raises an actionable error indicating that `roboplan.optimal_ik` is required for `kinematics.backend="roboplan"` + +### Requirement: Planning-group selection semantics for IK +RoboPlan IK SHALL use existing DimOS planning-group selection semantics for pose target and auxiliary groups. + +#### Scenario: Overlapping selected groups +- **WHEN** pose target groups or auxiliary groups select overlapping global joints +- **THEN** RoboPlan IK fails clearly without invoking Oink + +#### Scenario: Unsupported composite selection +- **WHEN** the non-overlapping selection cannot be represented by a RoboPlan native group or generated composite group +- **THEN** RoboPlan IK returns `IKStatus.NO_SOLUTION` with a message describing the unsupported selection + +#### Scenario: Auxiliary group retained +- **WHEN** an auxiliary group is selected without a pose target +- **THEN** RoboPlan IK includes that group's joints in the returned selected joint state using seed or current belief positions + +### Requirement: Seed, state, and result ordering +RoboPlan IK SHALL preserve DimOS public global joint ordering at API boundaries while converting to RoboPlan native group order internally. + +#### Scenario: Complete seed provided +- **WHEN** the caller provides a complete seed for the selected joints +- **THEN** RoboPlan IK initializes the Oink candidate from the provided seed + +#### Scenario: Incomplete or absent seed +- **WHEN** the caller omits seed positions required for selected joints +- **THEN** RoboPlan IK initializes missing selected joints from the RoboPlanWorld current belief state + +#### Scenario: Successful IK result order +- **WHEN** RoboPlan IK succeeds +- **THEN** `IKResult.joint_state.name` lists selected global joint names in `PlanningGroupSelection` order and positions correspond to those names + +### Requirement: Local IK semantics and final validation +RoboPlan IK SHALL behave as local endpoint inverse kinematics and SHALL not replace motion planning. + +#### Scenario: Tolerances reached and final state valid +- **WHEN** Oink iterations reach requested position and orientation tolerances and the final candidate passes configured final validation +- **THEN** RoboPlan IK returns `IKStatus.SUCCESS` + +#### Scenario: Iteration budget exhausted +- **WHEN** Oink iterations do not reach requested tolerances within the configured iteration budget +- **THEN** RoboPlan IK returns `IKStatus.NO_SOLUTION` with final error information when available + +#### Scenario: Final collision validation fails +- **WHEN** collision checking is enabled and the converged final candidate is colliding +- **THEN** RoboPlan IK returns a failure rather than a colliding endpoint + +#### Scenario: IK does not perform path planning +- **WHEN** RoboPlan IK returns a successful endpoint +- **THEN** the system does not imply that a collision-free path to the endpoint exists; callers that need motion SHALL still use a planner diff --git a/openspec/changes/roboplan-oink-kinematics/tasks.md b/openspec/changes/roboplan-oink-kinematics/tasks.md new file mode 100644 index 0000000000..1d622880ea --- /dev/null +++ b/openspec/changes/roboplan-oink-kinematics/tasks.md @@ -0,0 +1,36 @@ +## 1. Configuration and Factory Wiring + +- [ ] 1.1 Add `RoboPlanKinematicsConfig(backend="roboplan")` with Oink IK tuning fields and include it in `ManipulationKinematicsConfig`. +- [ ] 1.2 Update `kinematics_config_from_name("roboplan")`, `SUPPORTED_KINEMATICS`, and error messages to include RoboPlan kinematics. +- [ ] 1.3 Extend `validate_backend_combination(...)` so `kinematics_name="roboplan"` requires `world_backend="roboplan"`. +- [ ] 1.4 Update `create_kinematics(...)` to accept the existing `world` and `world_backend`, configure `RoboPlanWorld` for IK, and return it as `KinematicsSpec` for RoboPlan kinematics. +- [ ] 1.5 Update `create_planning_specs(...)` to pass the world into `create_kinematics(...)` while preserving existing non-RoboPlan kinematics behavior. + +## 2. RoboPlanWorld Kinematics Implementation + +- [ ] 2.1 Add role-specific IK config storage and a `configure_kinematics(...)` method on `RoboPlanWorld`. +- [ ] 2.2 Import or lazy-load `roboplan.optimal_ik` with clear actionable errors when the Oink binding is unavailable. +- [ ] 2.3 Implement `solve(...)` as a single-target convenience path or a clear unsupported path when no planning group can be identified. +- [ ] 2.4 Implement `solve_pose_targets(...)` using `PlanningGroupSelection` for pose and auxiliary groups. +- [ ] 2.5 Resolve selected groups to the RoboPlan native or generated composite group required by `Oink(scene, group_name)`. +- [ ] 2.6 Create one `FrameTask` per pose target using the target group's base/tip frame metadata and requested `PoseStamped` target. +- [ ] 2.7 Add `PositionLimit` and optional `VelocityLimit` constraints from the kinematics config. +- [ ] 2.8 Seed from the provided `JointState` when complete, otherwise fall back to the current RoboPlanWorld belief state. +- [ ] 2.9 Iterate `solveIk`, integrate the candidate, clamp/validate limits, check pose tolerances, and return selected global joints in selection order. +- [ ] 2.10 Optionally validate final collision freedom and fail clearly if the converged IK state is colliding. + +## 3. Tests + +- [ ] 3.1 Add config tests for parsing/defaults of `RoboPlanKinematicsConfig`. +- [ ] 3.2 Add factory tests that RoboPlan kinematics requires RoboPlan world backend and returns the same world object. +- [ ] 3.3 Add RoboPlanWorld unit tests for Oink task creation, multi-target task list construction, seed/current-state fallback, selected global joint return order, and auxiliary group retention. +- [ ] 3.4 Add failure tests for empty targets, missing pose target frame, overlapping selected groups, unsupported composite selection, missing `roboplan.optimal_ik`, non-convergence, and colliding final candidate. +- [ ] 3.5 Keep tests simulator-free with faked RoboPlan Oink bindings where possible. + +## 4. Validation and Documentation + +- [ ] 4.1 Run `uv run pytest dimos/manipulation/test_planning_factory.py -v`. +- [ ] 4.2 Run `uv run pytest dimos/manipulation/test_roboplan_world.py -v`. +- [ ] 4.3 Run targeted kinematics/config tests added for this change. +- [ ] 4.4 Run OpenSpec validation for `roboplan-oink-kinematics` and fix artifact issues. +- [ ] 4.5 Document any remaining Oink binding limitations discovered during implementation. From d49eb984be497130af588d47213f7131f96fe86b Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 21:04:55 -0700 Subject: [PATCH 087/110] feat: add roboplan cartesian planning --- .../planning/planners/rrt_planner.py | 13 + .../planners/test_rrt_planner_selection.py | 23 + dimos/manipulation/planning/spec/models.py | 42 ++ dimos/manipulation/planning/spec/protocols.py | 9 + .../manipulation/planning/spec/test_models.py | 88 ++++ .../planning/world/roboplan_world.py | 415 +++++++++++++++++- dimos/manipulation/test_roboplan_world.py | 393 ++++++++++++++++- .../specs/roboplan-cartesian-planning/spec.md | 92 ++++ 8 files changed, 1072 insertions(+), 3 deletions(-) create mode 100644 dimos/manipulation/planning/spec/test_models.py create mode 100644 openspec/specs/roboplan-cartesian-planning/spec.md diff --git a/dimos/manipulation/planning/planners/rrt_planner.py b/dimos/manipulation/planning/planners/rrt_planner.py index a100376053..c0e467f987 100644 --- a/dimos/manipulation/planning/planners/rrt_planner.py +++ b/dimos/manipulation/planning/planners/rrt_planner.py @@ -33,6 +33,7 @@ from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.enums import PlanningStatus from dimos.manipulation.planning.spec.models import ( + CartesianPlanningRequest, JointPath, PlanningResult, RobotName, @@ -242,6 +243,18 @@ def plan_selected_joint_path( timestamps=result.timestamps, ) + def plan_cartesian_path( + self, + world: WorldSpec, + request: CartesianPlanningRequest, + ) -> PlanningResult: + """Return explicit unsupported status for Cartesian planner requests.""" + _ = (world, request) + return _create_failure_result( + PlanningStatus.UNSUPPORTED, + "Cartesian planning is not supported by this planner", + ) + def _plan_multi_robot_selected_joint_path( self, world: WorldSpec, diff --git a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py index 4436aba595..a026335e35 100644 --- a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py +++ b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py @@ -27,6 +27,7 @@ from dimos.manipulation.planning.planners.rrt_planner import RRTConnectPlanner from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import PlanningStatus +from dimos.manipulation.planning.spec.models import CartesianPlanningRequest from dimos.manipulation.planning.spec.protocols import WorldSpec from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState @@ -245,3 +246,25 @@ def test_plan_selected_joint_path_rejects_single_robot_subset_selection() -> Non ) assert result.status == PlanningStatus.UNSUPPORTED + + +def test_plan_cartesian_path_returns_unsupported_without_ik_fallback() -> None: + group = _group("arm/manipulator", "arm", ("arm/joint1", "arm/joint2")) + world = _SelectionWorld(robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}) + + result = RRTConnectPlanner().plan_cartesian_path( + cast("WorldSpec", world), + CartesianPlanningRequest( + selection=_selection(group), + group_id="arm/manipulator", + start=_joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]), + target=PoseStamped(position=[0.1, 0.0, 0.0], orientation=[0.0, 0.0, 0.0, 1.0]), + target_mode="absolute", + ), + ) + + assert result.status == PlanningStatus.UNSUPPORTED + assert "Cartesian planning is not supported" in result.message + assert world.config_collision_names == [] + assert world.edge_collision_names == [] + assert world.coupled_collision_checks == 0 diff --git a/dimos/manipulation/planning/spec/models.py b/dimos/manipulation/planning/spec/models.py index f9f87e3398..d7a4033e0e 100644 --- a/dimos/manipulation/planning/spec/models.py +++ b/dimos/manipulation/planning/spec/models.py @@ -30,6 +30,7 @@ import numpy as np from numpy.typing import NDArray + from dimos.manipulation.planning.groups.models import PlanningGroupSelection from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState @@ -86,6 +87,47 @@ class PlanningSceneInfo: ] """Status for a group-scoped forward-kinematics query.""" +CartesianTargetMode: TypeAlias = Literal["absolute", "relative"] +"""Mode describing whether a Cartesian target is an absolute pose or relative delta.""" + +CartesianPathMode: TypeAlias = Literal["free", "linear"] +"""Mode describing requested Cartesian path semantics.""" + + +@dataclass(frozen=True) +class CartesianDelta: + """Relative TCP delta for Cartesian planning. + + Translation is meters. Rotation is roll, pitch, yaw in radians. The frame is the + frame in which the delta is expressed. + """ + + translation: tuple[float, float, float] = (0.0, 0.0, 0.0) + rotation_rpy: tuple[float, float, float] = (0.0, 0.0, 0.0) + frame_id: str = "world" + + +@dataclass(frozen=True) +class CartesianPlanningRequest: + """Planner-native Cartesian path request. + + `selection` defines the selected global joints to plan and return. `group_id` + identifies the selected planning group's TCP/target frame whose pose should reach + or follow the Cartesian target. `start` contains exactly the selected global joints + in caller order. + """ + + selection: PlanningGroupSelection + group_id: PlanningGroupID + start: JointState + target: PoseStamped | CartesianDelta + target_mode: CartesianTargetMode + path_mode: CartesianPathMode = "free" + reference_frame: str = "world" + max_translation_step: float = 0.01 + max_rotation_step: float = 0.05 + timeout: float = 10.0 + @dataclass(frozen=True) class CollisionCheckResult: diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index 3c0dca3cf5..85a9f1f15b 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -32,6 +32,7 @@ from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.models import ( + CartesianPlanningRequest, GeneratedPlan, IKResult, Obstacle, @@ -291,6 +292,14 @@ def plan_selected_joint_path( """Plan over an explicit planning-group selection.""" ... + def plan_cartesian_path( + self, + world: WorldSpec, + request: CartesianPlanningRequest, + ) -> PlanningResult: + """Plan over a Cartesian TCP target for a selected planning group.""" + ... + def get_name(self) -> str: """Get planner name.""" ... diff --git a/dimos/manipulation/planning/spec/test_models.py b/dimos/manipulation/planning/spec/test_models.py new file mode 100644 index 0000000000..0836d9f8c6 --- /dev/null +++ b/dimos/manipulation/planning/spec/test_models.py @@ -0,0 +1,88 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for planning model contracts.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection +from dimos.manipulation.planning.spec.models import CartesianDelta, CartesianPlanningRequest +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.JointState import JointState + +GROUP = PlanningGroup( + id="arm/manipulator", + robot_name="arm", + group_name="manipulator", + joint_names=("arm/joint1", "arm/joint2"), + local_joint_names=("joint1", "joint2"), + base_link="base", + tip_link="tcp", +) +SELECTION = PlanningGroupSelection.from_groups((GROUP,)) +START = JointState({"name": ["arm/joint1", "arm/joint2"], "position": [0.0, 0.0]}) + + +def test_cartesian_planning_request_carries_absolute_pose_target() -> None: + target = PoseStamped(frame_id="world", position=[0.1, 0.2, 0.3]) + + request = CartesianPlanningRequest( + selection=SELECTION, + group_id="arm/manipulator", + start=START, + target=target, + target_mode="absolute", + ) + + assert request.target is target + assert request.target_mode == "absolute" + assert request.path_mode == "free" + assert request.reference_frame == "world" + + +def test_cartesian_planning_request_carries_relative_delta_target() -> None: + delta = CartesianDelta(translation=(0.1, 0.0, 0.0), rotation_rpy=(0.0, 0.0, 0.2)) + + request = CartesianPlanningRequest( + selection=SELECTION, + group_id="arm/manipulator", + start=START, + target=delta, + target_mode="relative", + path_mode="linear", + ) + + assert request.target is delta + assert request.target_mode == "relative" + assert request.path_mode == "linear" + + +def test_cartesian_models_are_frozen() -> None: + delta = CartesianDelta() + request = CartesianPlanningRequest( + selection=SELECTION, + group_id="arm/manipulator", + start=START, + target=delta, + target_mode="relative", + ) + + with pytest.raises(FrozenInstanceError): + delta.frame_id = "tool" # type: ignore[misc] + with pytest.raises(FrozenInstanceError): + request.path_mode = "linear" # type: ignore[misc] diff --git a/dimos/manipulation/planning/world/roboplan_world.py b/dimos/manipulation/planning/world/roboplan_world.py index a2739afc99..32126e25f2 100644 --- a/dimos/manipulation/planning/world/roboplan_world.py +++ b/dimos/manipulation/planning/world/roboplan_world.py @@ -35,6 +35,7 @@ from xml.sax.saxutils import escape import numpy as np +from scipy.spatial.transform import Rotation as R try: import roboplan.core as roboplan_core @@ -45,11 +46,22 @@ "Install the manipulation extra before selecting the roboplan backend." ) from exc +try: + import roboplan.simple_ik as roboplan_simple_ik +except ImportError: + roboplan_simple_ik = None + from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry from dimos.manipulation.planning.groups.utils import joint_target_to_global_names from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import ObstacleType, PlanningStatus -from dimos.manipulation.planning.spec.models import Obstacle, PlanningResult, WorldRobotID +from dimos.manipulation.planning.spec.models import ( + CartesianDelta, + CartesianPlanningRequest, + Obstacle, + PlanningResult, + WorldRobotID, +) from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake from dimos.manipulation.planning.utils.path_utils import compute_path_length from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -64,11 +76,14 @@ from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName + from dimos.manipulation.planning.spec.protocols import WorldSpec logger = setup_logger() _NATIVE_NAME_SEPARATOR = "__" _COMPOSITE_GROUP_PREFIX = "_dimos_composite" +_CARTESIAN_POSITION_TOLERANCE = 1e-3 +_CARTESIAN_ORIENTATION_TOLERANCE = 1e-2 @dataclass @@ -424,6 +439,59 @@ def get_group_jacobian( # PlannerSpec for native RoboPlan planning + def plan_joint_path( + self, + world: WorldSpec, + robot_id: WorldRobotID, + start: JointState, + goal: JointState, + timeout: float = 10.0, + ) -> PlanningResult: + """Plan a robot-scoped joint path using the robot's full planning group.""" + if world is not self: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan-native planner requires its RoboPlanWorld instance", + ) + self._require_finalized() + + try: + robot = self._get_robot(robot_id) + except KeyError as exc: + return PlanningResult(status=PlanningStatus.INVALID_GOAL, message=str(exc)) + + group = self._full_robot_planning_group(robot.config.name) + if group is None: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message=( + "RoboPlan-native joint planning requires a planning group that covers " + "the robot controllable joint set exactly" + ), + ) + + selection = self._planning_groups.select((group.id,)) + result = self.plan_selected_joint_path( + self, + selection, + start, + goal, + timeout=timeout, + ) + if not result.is_success(): + return result + + path = self._selection_path_to_robot_joint_path(selection, group, robot, result.path) + return PlanningResult( + status=result.status, + path=path, + planning_time=result.planning_time, + path_length=compute_path_length(path), + iterations=result.iterations, + message=result.message, + timestamps=result.timestamps, + ) + def plan_selected_joint_path( self, world: Any, @@ -491,12 +559,337 @@ def plan_selected_joint_path( message="RoboPlan path found", ) + def plan_cartesian_path( + self, + world: WorldSpec, + request: CartesianPlanningRequest, + ) -> PlanningResult: + """Plan a RoboPlan-native free Cartesian path to a selected TCP target.""" + if world is not self: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan-native Cartesian planner requires its RoboPlanWorld instance", + ) + self._require_finalized() + + start_time = time.time() + invalid = self._validate_cartesian_request(request) + if invalid is not None: + invalid.planning_time = time.time() - start_time + return invalid + if request.path_mode == "linear": + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + planning_time=time.time() - start_time, + message="Linear Cartesian planning is not supported by the RoboPlan adapter yet", + ) + if roboplan_simple_ik is None: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + planning_time=time.time() - start_time, + message="RoboPlan Cartesian planning requires roboplan.simple_ik", + ) + + try: + group = self._planning_groups.get(request.group_id) + group_data = self._group_data_for_selection_ids(request.selection.group_ids) + with self.scratch_context() as ctx: + self._set_selection_state(ctx, request.selection, request.start) + self._set_scene_current_q(ctx) + target_pose = self._resolve_cartesian_target(ctx, request) + q_start = self._joint_state_to_native_selection_q( + request.selection, group_data, request.start + ) + q_goal = self._solve_cartesian_goal( + group, + group_data, + q_start, + target_pose, + request.timeout, + ) + result = self._run_native_rrt( + group_data.group_name, + group_data.native_joint_names, + q_start, + q_goal, + request.timeout, + ) + result_names, path_arrays = self._extract_native_path(result) + path = self._native_path_to_public_selection_path( + request.selection, group_data, path_arrays, result_names + ) + if path and not self._selection_path_collision_free(request.selection, path): + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message="RoboPlan Cartesian planning failed: returned path is in collision", + ) + if path and not self._cartesian_final_pose_matches( + request.selection, request.group_id, path[-1], target_pose + ): + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message="RoboPlan Cartesian planning failed: final TCP pose missed target", + ) + except ValueError as exc: + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + planning_time=time.time() - start_time, + message=str(exc), + ) + except Exception as exc: + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message=f"RoboPlan Cartesian planning failed: {exc}", + ) + if not path: + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message="RoboPlan Cartesian planning failed: returned an empty path", + ) + return PlanningResult( + status=PlanningStatus.SUCCESS, + path=path, + planning_time=time.time() - start_time, + path_length=compute_path_length(path), + message="RoboPlan Cartesian path found", + ) + def get_name(self) -> str: """Get planner name.""" return "RoboPlan" # Internals + def _validate_cartesian_request( + self, request: CartesianPlanningRequest + ) -> PlanningResult | None: + if request.target_mode not in ("absolute", "relative"): + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message=f"Unsupported Cartesian target_mode: {request.target_mode}", + ) + if request.path_mode not in ("free", "linear"): + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message=f"Unsupported Cartesian path_mode: {request.path_mode}", + ) + if request.reference_frame != "world": + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan Cartesian planning currently supports only world reference_frame", + ) + if request.max_translation_step <= 0.0: + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message="max_translation_step must be positive", + ) + if request.max_rotation_step <= 0.0: + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message="max_rotation_step must be positive", + ) + if request.timeout <= 0.0: + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, message="timeout must be positive" + ) + if request.selection.group_ids != (request.group_id,): + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan Cartesian planning currently supports exactly one selected TCP group", + ) + + try: + group = self._planning_groups.get(request.group_id) + except KeyError as exc: + return PlanningResult(status=PlanningStatus.UNSUPPORTED, message=str(exc)) + if group.tip_link is None: + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message=f"Planning group '{request.group_id}' has no pose target frame", + ) + unsupported = self._validate_supported_selection(request.selection) + if unsupported is not None: + return unsupported + try: + self._selection_positions_by_global(request.selection, request.start) + except ValueError as exc: + return PlanningResult(status=PlanningStatus.INVALID_START, message=str(exc)) + + if request.target_mode == "absolute": + if not isinstance(request.target, PoseStamped): + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message="absolute Cartesian target_mode requires a PoseStamped target", + ) + if request.target.frame_id not in ("", "world"): + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan Cartesian planning currently supports only world-frame poses", + ) + return None + + if not isinstance(request.target, CartesianDelta): + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message="relative Cartesian target_mode requires a CartesianDelta target", + ) + if request.target.frame_id != "world": + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan Cartesian planning currently supports only world-frame deltas", + ) + return None + + def _resolve_cartesian_target( + self, ctx: RoboPlanContext, request: CartesianPlanningRequest + ) -> PoseStamped: + if request.target_mode == "absolute": + if not isinstance(request.target, PoseStamped): + raise ValueError("absolute Cartesian target_mode requires a PoseStamped target") + return PoseStamped( + frame_id="world", + position=request.target.position, + orientation=request.target.orientation, + ) + + if not isinstance(request.target, CartesianDelta): + raise ValueError("relative Cartesian target_mode requires a CartesianDelta target") + start_pose = self.get_group_ee_pose(ctx, request.group_id) + start_matrix = pose_to_matrix(start_pose) + target_matrix = start_matrix.copy() + target_matrix[:3, 3] = start_matrix[:3, 3] + np.asarray( + request.target.translation, dtype=np.float64 + ) + delta_rotation = R.from_euler("xyz", request.target.rotation_rpy).as_matrix() + target_matrix[:3, :3] = delta_rotation @ start_matrix[:3, :3] + pose = matrix_to_pose(target_matrix) + return PoseStamped(frame_id="world", position=pose.position, orientation=pose.orientation) + + def _solve_cartesian_goal( + self, + group: PlanningGroup, + group_data: _RoboPlanGroupData, + q_start: NDArray[np.float64], + target_pose: PoseStamped, + timeout: float, + ) -> NDArray[np.float64]: + if roboplan_simple_ik is None: + raise ValueError("RoboPlan SimpleIk is unavailable") + options_cls = getattr(roboplan_simple_ik, "SimpleIkOptions", None) + solver_cls = getattr(roboplan_simple_ik, "SimpleIk", None) + if options_cls is None or solver_cls is None: + raise ValueError("roboplan.simple_ik does not expose SimpleIk and SimpleIkOptions") + + options = options_cls() + self._configure_simple_ik_options(options, timeout) + solver = solver_cls(self._require_scene(), options) + goal = self._to_native_cartesian_configuration(group, target_pose) + start_config = self._to_native_joint_configuration(group_data.native_joint_names, q_start) + solution_config = self._to_native_joint_configuration( + group_data.native_joint_names, q_start + ) + solve_ik = getattr(solver, "solveIk", None) + if solve_ik is None: + raise ValueError("RoboPlan SimpleIk solver does not expose solveIk") + result = solve_ik(goal, start_config, solution_config) + if isinstance(result, bool): + if not result: + raise ValueError("RoboPlan SimpleIk failed to solve Cartesian target") + elif result is not None: + solution_config = result + return self._native_joint_configuration_to_q(group_data.native_joint_names, solution_config) + + def _configure_simple_ik_options(self, options: Any, timeout: float) -> None: + for name, value in ( + ("max_time", timeout), + ("timeout", timeout), + ("max_solve_time", timeout), + ("max_iterations", 100), + ): + try: + setattr(options, name, value) + except AttributeError: + continue + + def _to_native_cartesian_configuration( + self, group: PlanningGroup, target_pose: PoseStamped + ) -> Any: + config_cls = getattr(roboplan_core, "CartesianConfiguration", None) + if config_cls is None: + raise ValueError("roboplan.core does not expose CartesianConfiguration") + if group.tip_link is None: + raise ValueError(f"Planning group '{group.id}' has no pose target frame") + frame_name = self._native_link_name( + self._get_robot(self._robot_ids_by_name[group.robot_name]).config, group.tip_link + ) + matrix = pose_to_matrix(target_pose) + for args in ((frame_name, matrix), (matrix, frame_name)): + try: + return config_cls(*args) + except TypeError: + continue + config = config_cls() + for name, value in ( + ("frame_name", frame_name), + ("frame", frame_name), + ("matrix", matrix), + ("pose", matrix), + ("transform", matrix), + ): + try: + setattr(config, name, value) + except AttributeError: + continue + return config + + def _native_joint_configuration_to_q( + self, native_joint_names: tuple[str, ...], config: Any + ) -> NDArray[np.float64]: + positions = np.asarray(getattr(config, "positions", []), dtype=np.float64) + if len(positions) != len(native_joint_names): + raise ValueError( + "RoboPlan SimpleIk solution length does not match selected planning group: " + f"{len(positions)} != {len(native_joint_names)}" + ) + result_joint_names = getattr(config, "joint_names", None) + if not result_joint_names: + return positions + names = tuple(result_joint_names) + if len(set(names)) != len(names): + raise ValueError("RoboPlan SimpleIk returned duplicate joint names") + if set(names) != set(native_joint_names): + raise ValueError( + "RoboPlan SimpleIk joint names do not match selected planning group: " + f"RoboPlan={list(names)}, configured={list(native_joint_names)}" + ) + positions_by_name = dict(zip(names, positions, strict=True)) + return np.asarray([positions_by_name[name] for name in native_joint_names]) + + def _cartesian_final_pose_matches( + self, + selection: PlanningGroupSelection, + group_id: PlanningGroupID, + final_state: JointState, + target_pose: PoseStamped, + ) -> bool: + with self.scratch_context() as ctx: + self._set_selection_state(ctx, selection, final_state) + actual_pose = self.get_group_ee_pose(ctx, group_id) + actual_matrix = pose_to_matrix(actual_pose) + target_matrix = pose_to_matrix(target_pose) + position_error = float(np.linalg.norm(actual_matrix[:3, 3] - target_matrix[:3, 3])) + rotation_error = float( + R.from_matrix(target_matrix[:3, :3].T @ actual_matrix[:3, :3]).magnitude() + ) + return ( + position_error <= _CARTESIAN_POSITION_TOLERANCE + and rotation_error <= _CARTESIAN_ORIENTATION_TOLERANCE + ) + def _create_scene(self) -> Any: if self._uses_composite_model: urdf_path, srdf_path, package_paths = self._prepare_composite_model() @@ -1220,6 +1613,26 @@ def _selection_positions_by_global( raise ValueError(f"JointState missing selected joints: {missing}") return positions_by_global + def _selection_path_to_robot_joint_path( + self, + selection: PlanningGroupSelection, + group: PlanningGroup, + robot: _RoboPlanRobotData, + path: list[JointState], + ) -> list[JointState]: + result: list[JointState] = [] + for state in path: + positions_by_global = self._selection_positions_by_global(selection, state) + result.append( + JointState( + name=list(robot.config.joint_names), + position=[ + positions_by_global[global_name] for global_name in group.joint_names + ], + ) + ) + return result + def _validate_selected_start_matches_belief( self, selection: PlanningGroupSelection, start: JointState ) -> PlanningResult | None: diff --git a/dimos/manipulation/test_roboplan_world.py b/dimos/manipulation/test_roboplan_world.py index 8959cd49e3..1f9a9fb003 100644 --- a/dimos/manipulation/test_roboplan_world.py +++ b/dimos/manipulation/test_roboplan_world.py @@ -32,7 +32,11 @@ ) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import ObstacleType, PlanningStatus -from dimos.manipulation.planning.spec.models import Obstacle +from dimos.manipulation.planning.spec.models import ( + CartesianDelta, + CartesianPlanningRequest, + Obstacle, +) from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -54,6 +58,12 @@ def __init__(self, joint_names: list[str], positions: list[np.ndarray]) -> None: self.positions = positions +class FakeCartesianConfiguration: + def __init__(self, frame_name: str = "", matrix: np.ndarray | None = None) -> None: + self.frame_name = frame_name + self.matrix = np.asarray(matrix if matrix is not None else np.eye(4), dtype=np.float64) + + class FakeJointGroupInfo: def __init__(self, joint_names: list[str]) -> None: self.joint_names = joint_names @@ -63,6 +73,7 @@ class FakeScene: joint_group_joint_names: ClassVar[list[str] | None] = None position_limits_lower: ClassVar[list[float]] = [-1.0, -2.0] position_limits_upper: ClassVar[list[float]] = [1.0, 2.0] + fk_rotation: ClassVar[np.ndarray | None] = None def __init__(self, *args: Any) -> None: self.constructor_args = args @@ -128,6 +139,8 @@ def removeGeometry(self, handle: str) -> None: def forwardKinematics(self, q: np.ndarray, frame_name: str, base_frame: str = "") -> np.ndarray: _ = (frame_name, base_frame) mat = np.eye(4) + if self.fk_rotation is not None: + mat[:3, :3] = self.fk_rotation mat[0, 3] = float(np.sum(q)) return mat @@ -174,12 +187,50 @@ def plan( ) -def _install_fake_roboplan(monkeypatch: pytest.MonkeyPatch) -> None: +class FakeSimpleIkOptions: + def __init__(self) -> None: + self.max_time = 0.0 + self.timeout = 0.0 + self.max_solve_time = 0.0 + self.max_iterations = 0 + + +class FakeSimpleIk: + last_options: ClassVar[FakeSimpleIkOptions | None] = None + last_goal: ClassVar[FakeCartesianConfiguration | None] = None + last_start: ClassVar[FakeJointConfiguration | None] = None + + def __init__(self, scene: FakeScene, options: FakeSimpleIkOptions) -> None: + self.scene = scene + self.options = options + FakeSimpleIk.last_options = options + + def solveIk( + self, + goal: FakeCartesianConfiguration, + start: FakeJointConfiguration, + solution: FakeJointConfiguration, + ) -> bool: + _ = self.scene + FakeSimpleIk.last_goal = goal + FakeSimpleIk.last_start = start + joint_count = len(start.joint_names) + if joint_count == 0: + return False + solution.joint_names = list(start.joint_names) + solution.positions = np.full(joint_count, goal.matrix[0, 3] / joint_count) + return True + + +def _install_fake_roboplan( + monkeypatch: pytest.MonkeyPatch, *, include_simple_ik: bool = False +) -> None: roboplan_pkg = ModuleType("roboplan") roboplan_pkg.__path__ = [] # type: ignore[attr-defined] core = ModuleType("roboplan.core") core.Scene = FakeScene # type: ignore[attr-defined] core.JointConfiguration = FakeJointConfiguration # type: ignore[attr-defined] + core.CartesianConfiguration = FakeCartesianConfiguration # type: ignore[attr-defined] def has_collisions_along_path( scene: FakeScene, @@ -204,6 +255,11 @@ def has_collisions_along_path( monkeypatch.setitem(sys.modules, "roboplan", roboplan_pkg) monkeypatch.setitem(sys.modules, "roboplan.core", core) monkeypatch.setitem(sys.modules, "roboplan.rrt", rrt) + if include_simple_ik: + simple_ik = ModuleType("roboplan.simple_ik") + simple_ik.SimpleIkOptions = FakeSimpleIkOptions # type: ignore[attr-defined] + simple_ik.SimpleIk = FakeSimpleIk # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "roboplan.simple_ik", simple_ik) @pytest.fixture @@ -784,6 +840,339 @@ def test_selected_native_planner_converts_path( assert FakeRRT.last_options.collision_check_step_size == pytest.approx(0.02) +def test_robot_scoped_native_planner_satisfies_planner_spec( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + start = JointState(name=["joint1", "joint2"], position=[0.0, 0.0]) + goal = JointState(name=["joint1", "joint2"], position=[0.4, 0.2]) + result = world.plan_joint_path(world, robot_id, start, goal, timeout=1.0) + + assert result.status == PlanningStatus.SUCCESS + assert [state.position for state in result.path] == [[0.0, 0.0], [0.2, 0.1], [0.4, 0.2]] + assert [state.name for state in result.path] == [["joint1", "joint2"]] * 3 + assert FakeRRT.last_options is not None + assert FakeRRT.last_options.group_name == "manipulator" + + +def test_cartesian_free_requires_optional_simple_ik( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + CartesianPlanningRequest( + selection=selection, + group_id="arm/manipulator", + start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + target=PoseStamped(frame_id="world", position=Vector3(0.2, 0.0, 0.0)), # type: ignore[call-arg] + target_mode="absolute", + ), + ) + + assert result.status == PlanningStatus.UNSUPPORTED + assert "simple_ik" in result.message + + +def test_cartesian_free_absolute_uses_simple_ik_then_rrt( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_simple_ik=True) + module = _import_roboplan_world(None) + FakeRRT.last_start = None + FakeRRT.last_goal = None + FakeSimpleIk.last_goal = None + world = module.RoboPlanWorld() + robot_id = world.add_robot(robot_config) + world.finalize() + world.set_joint_state( + world.get_live_context(), robot_id, JointState(name=[], position=[0.8, 0.1]) + ) + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + CartesianPlanningRequest( + selection=selection, + group_id="arm/manipulator", + start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.1]), + target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] + target_mode="absolute", + timeout=2.0, + ), + ) + + assert result.status == PlanningStatus.SUCCESS + assert [state.name for state in result.path] == [["arm/joint1", "arm/joint2"]] * 3 + assert result.path[-1].position == [0.2, 0.2] + assert FakeSimpleIk.last_options is not None + assert FakeSimpleIk.last_options.max_time == pytest.approx(2.0) + assert FakeSimpleIk.last_goal is not None + assert FakeSimpleIk.last_goal.frame_name == "tcp" + assert FakeRRT.last_start is not None + np.testing.assert_allclose(FakeRRT.last_start.positions, [0.1, 0.1]) + np.testing.assert_allclose(world._scene.current_positions, [0.1, 0.1]) + + +def test_cartesian_free_relative_delta_uses_world_axes( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_simple_ik=True) + module = _import_roboplan_world(None) + rotation_z_90 = np.asarray( + [ + [0.0, -1.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ] + ) + monkeypatch.setattr(FakeScene, "fk_rotation", rotation_z_90) + FakeSimpleIk.last_goal = None + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + CartesianPlanningRequest( + selection=selection, + group_id="arm/manipulator", + start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.2]), + target=CartesianDelta(translation=(0.2, 0.0, 0.0), frame_id="world"), + target_mode="relative", + ), + ) + + assert result.status == PlanningStatus.SUCCESS + assert FakeSimpleIk.last_goal is not None + np.testing.assert_allclose(FakeSimpleIk.last_goal.matrix[:3, 3], [0.5, 0.0, 0.0]) + np.testing.assert_allclose(FakeSimpleIk.last_goal.matrix[:3, :3], rotation_z_90, atol=1e-12) + + +def test_cartesian_free_relative_delta_composes_world_rotation( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_simple_ik=True) + module = _import_roboplan_world(None) + rotation_z_90 = np.asarray( + [ + [0.0, -1.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ] + ) + rotation_z_180 = np.asarray( + [ + [-1.0, 0.0, 0.0], + [0.0, -1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ) + monkeypatch.setattr(FakeScene, "fk_rotation", rotation_z_90) + FakeSimpleIk.last_goal = None + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + CartesianPlanningRequest( + selection=selection, + group_id="arm/manipulator", + start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.2]), + target=CartesianDelta(rotation_rpy=(0.0, 0.0, np.pi / 2.0), frame_id="world"), + target_mode="relative", + ), + ) + + assert result.status == PlanningStatus.NO_SOLUTION + assert "final TCP pose missed target" in result.message + assert FakeSimpleIk.last_goal is not None + np.testing.assert_allclose(FakeSimpleIk.last_goal.matrix[:3, :3], rotation_z_180, atol=1e-12) + + +def test_cartesian_free_malformed_start_returns_invalid_start( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + CartesianPlanningRequest( + selection=selection, + group_id="arm/manipulator", + start=JointState(name=["arm/joint1"], position=[0.0]), + target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] + target_mode="absolute", + ), + ) + + assert result.status == PlanningStatus.INVALID_START + + +def test_cartesian_free_rejects_final_tcp_mismatch( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_simple_ik=True) + + class MismatchedPoseSimpleIk(FakeSimpleIk): + def solveIk( + self, + goal: FakeCartesianConfiguration, + start: FakeJointConfiguration, + solution: FakeJointConfiguration, + ) -> bool: + FakeSimpleIk.last_goal = goal + FakeSimpleIk.last_start = start + solution.joint_names = list(start.joint_names) + solution.positions = np.zeros(len(start.joint_names), dtype=np.float64) + return True + + monkeypatch.setattr(sys.modules["roboplan.simple_ik"], "SimpleIk", MismatchedPoseSimpleIk) + module = _import_roboplan_world(None) + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + CartesianPlanningRequest( + selection=selection, + group_id="arm/manipulator", + start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.1]), + target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] + target_mode="absolute", + ), + ) + + assert result.status == PlanningStatus.NO_SOLUTION + assert "final TCP pose missed target" in result.message + + +def test_cartesian_free_rejects_coupled_selection_for_v1( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + group = world._planning_groups.get("arm/manipulator") + selection = PlanningGroupSelection( + groups=(group,), + group_ids=(group.id, "other/manipulator"), + joint_names=group.joint_names, + robot_names=(group.robot_name,), + ) + + result = world.plan_cartesian_path( + world, + CartesianPlanningRequest( + selection=selection, + group_id="arm/manipulator", + start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] + target_mode="absolute", + ), + ) + + assert result.status == PlanningStatus.UNSUPPORTED + assert "exactly one selected TCP group" in result.message + + +def test_cartesian_linear_mode_is_explicitly_unsupported_without_free_fallback( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_simple_ik=True) + module = _import_roboplan_world(None) + FakeRRT.last_start = None + FakeSimpleIk.last_goal = None + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + CartesianPlanningRequest( + selection=selection, + group_id="arm/manipulator", + start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] + target_mode="absolute", + path_mode="linear", + ), + ) + + assert result.status == PlanningStatus.UNSUPPORTED + assert "Linear Cartesian planning" in result.message + assert FakeSimpleIk.last_goal is None + assert FakeRRT.last_start is None + + +@pytest.mark.parametrize( + ("request_update", "expected_status", "expected_message"), + [ + ({"target_mode": "nonsense"}, PlanningStatus.INVALID_GOAL, "target_mode"), + ({"path_mode": "nonsense"}, PlanningStatus.INVALID_GOAL, "path_mode"), + ({"reference_frame": "tool"}, PlanningStatus.UNSUPPORTED, "reference_frame"), + ({"max_translation_step": 0.0}, PlanningStatus.INVALID_GOAL, "max_translation_step"), + ({"max_rotation_step": 0.0}, PlanningStatus.INVALID_GOAL, "max_rotation_step"), + ({"timeout": 0.0}, PlanningStatus.INVALID_GOAL, "timeout"), + ( + {"target": CartesianDelta(), "target_mode": "absolute"}, + PlanningStatus.INVALID_GOAL, + "PoseStamped", + ), + ( + {"target": PoseStamped(frame_id="map"), "target_mode": "absolute"}, + PlanningStatus.UNSUPPORTED, + "world-frame poses", + ), + ( + {"target": PoseStamped(frame_id="world"), "target_mode": "relative"}, + PlanningStatus.INVALID_GOAL, + "CartesianDelta", + ), + ( + {"target": CartesianDelta(frame_id="tool"), "target_mode": "relative"}, + PlanningStatus.UNSUPPORTED, + "world-frame deltas", + ), + ], +) +def test_cartesian_request_validation_branches( + fake_roboplan: None, + robot_config: RobotModelConfig, + request_update: dict[str, object], + expected_status: PlanningStatus, + expected_message: str, +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + request_kwargs = { + "selection": selection, + "group_id": "arm/manipulator", + "start": JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + "target": PoseStamped(frame_id="world", position=Vector3(0.2, 0.0, 0.0)), + "target_mode": "absolute", + } + request_kwargs.update(request_update) + + result = world.plan_cartesian_path(world, CartesianPlanningRequest(**request_kwargs)) + + assert result.status == expected_status + assert expected_message in result.message + + def test_selected_native_planner_handles_unnamed_group_states( fake_roboplan: None, robot_config: RobotModelConfig ) -> None: diff --git a/openspec/specs/roboplan-cartesian-planning/spec.md b/openspec/specs/roboplan-cartesian-planning/spec.md new file mode 100644 index 0000000000..ca9ef6e2fb --- /dev/null +++ b/openspec/specs/roboplan-cartesian-planning/spec.md @@ -0,0 +1,92 @@ +## Purpose + +Define planner-level Cartesian path planning for RoboPlan-backed planners, including absolute Cartesian goals, relative Cartesian deltas, and linear TCP path mode. + +## Requirements + +### Requirement: PlannerSpec exposes Cartesian path planning +`PlannerSpec` SHALL expose `plan_cartesian_path(world: WorldSpec, request: CartesianPlanningRequest) -> PlanningResult` for planner-level Cartesian TCP requests. + +#### Scenario: Planner receives Cartesian request object +- **WHEN** a caller has a selected planning group, start joint state, and Cartesian target +- **THEN** the caller SHALL be able to invoke `plan_cartesian_path(...)` with one `CartesianPlanningRequest` +- **AND** the planner SHALL return a `PlanningResult` + +#### Scenario: Cartesian planning does not replace joint planning +- **WHEN** a caller already has a joint-space goal +- **THEN** the caller SHALL continue to use `plan_selected_joint_path(...)` +- **AND** `plan_cartesian_path(...)` SHALL be reserved for TCP-space targets or deltas + +### Requirement: CartesianPlanningRequest defines the public API +The system SHALL define `CartesianPlanningRequest` with fields `selection: PlanningGroupSelection`, `group_id: PlanningGroupID`, `start: JointState`, `target: PoseStamped | CartesianDelta`, `target_mode: Literal["absolute", "relative"]`, `path_mode: Literal["free", "linear"] = "free"`, `reference_frame: str = "world"`, `max_translation_step: float = 0.01`, `max_rotation_step: float = 0.05`, and `timeout: float = 10.0`. + +#### Scenario: Request names the planned joints and active TCP group +- **WHEN** a Cartesian request is constructed +- **THEN** `selection` SHALL define the selected global joints that are planned and returned +- **AND** `group_id` SHALL identify the planning group's TCP/target frame that receives the Cartesian target + +#### Scenario: Request controls path mode +- **WHEN** a Cartesian request sets `path_mode` to `"free"` or `"linear"` +- **THEN** the planner SHALL interpret that value as the requested Cartesian path semantics + +### Requirement: CartesianDelta defines relative Cartesian targets +The system SHALL define `CartesianDelta` with fields `translation: tuple[float, float, float] = (0.0, 0.0, 0.0)`, `rotation_rpy: tuple[float, float, float] = (0.0, 0.0, 0.0)`, and `frame_id: str = "world"`. + +#### Scenario: Relative target uses delta model +- **WHEN** `CartesianPlanningRequest.target_mode` is `"relative"` +- **THEN** `target` SHALL be a `CartesianDelta` +- **AND** translation SHALL be interpreted in meters +- **AND** rotation SHALL be interpreted as roll, pitch, yaw in radians + +#### Scenario: Absolute target uses stamped pose +- **WHEN** `CartesianPlanningRequest.target_mode` is `"absolute"` +- **THEN** `target` SHALL be a `PoseStamped` + +### Requirement: Free Cartesian mode plans to the target pose +For `path_mode="free"`, the planner SHALL plan a collision-free selected-joint path whose final TCP pose satisfies the Cartesian target without requiring straight-line TCP motion between start and target. + +#### Scenario: Absolute free Cartesian target +- **WHEN** a RoboPlan-backed planner receives an absolute Cartesian request with `path_mode="free"` +- **THEN** it SHALL plan to the requested final TCP pose if supported and feasible +- **AND** it SHALL return selected global-joint waypoints in `PlanningResult.path` + +#### Scenario: Relative free Cartesian target +- **WHEN** a RoboPlan-backed planner receives a relative Cartesian request with `path_mode="free"` +- **THEN** it SHALL compute the start TCP pose from `request.start` and `request.group_id` +- **AND** it SHALL apply `CartesianDelta` to derive the final target pose before planning + +### Requirement: Linear Cartesian mode preserves straight-line TCP intent +For `path_mode="linear"`, the planner SHALL require or request straight-line TCP motion from the start TCP pose to the target TCP pose according to the request interpolation limits. + +#### Scenario: Linear mode succeeds only for linear TCP path +- **WHEN** a RoboPlan-backed planner returns success for `path_mode="linear"` +- **THEN** the returned joint waypoints SHALL represent a TCP path that honors the requested straight-line Cartesian segment within the request interpolation limits + +#### Scenario: Linear mode is not silently downgraded +- **WHEN** the planner cannot support or verify linear TCP path semantics +- **THEN** it SHALL return `PlanningStatus.UNSUPPORTED` or a non-success planning status +- **AND** it SHALL NOT return a successful free-space joint path as if it satisfied linear mode + +### Requirement: Unsupported planners fail explicitly +Planners that do not support Cartesian planning SHALL return `PlanningStatus.UNSUPPORTED` from `plan_cartesian_path(...)` with an explanatory message. + +#### Scenario: Generic joint planner receives Cartesian request +- **WHEN** a non-Cartesian planner receives `plan_cartesian_path(...)` +- **THEN** it SHALL return `PlanningStatus.UNSUPPORTED` +- **AND** it SHALL NOT perform an implicit IK-to-joint-plan fallback + +### Requirement: RoboPlanWorld implements Cartesian planning as PlannerSpec +`RoboPlanWorld` SHALL implement `plan_cartesian_path(...)` because it is the RoboPlan-backed object that implements both `WorldSpec` and `PlannerSpec`. + +#### Scenario: RoboPlanWorld returns public joint-state path +- **WHEN** RoboPlan-backed Cartesian planning succeeds +- **THEN** `RoboPlanWorld` SHALL convert the native path into `PlanningResult.path` using public global joint names in the caller's selected order + +#### Scenario: RoboPlan native details stay internal +- **WHEN** callers use `plan_cartesian_path(...)` +- **THEN** callers SHALL NOT need to import or pass `roboplan.*` objects +- **AND** RoboPlan-specific options SHALL remain inside the RoboPlan adapter/configuration + +#### Scenario: RoboPlan request validation fails clearly +- **WHEN** `target_mode`, `target`, `group_id`, selected joints, or interpolation limits are invalid or unsupported +- **THEN** `RoboPlanWorld` SHALL return an appropriate non-success `PlanningResult` with an explanatory message From b8c361e2874f77838ea4ab926f9592df9b597668 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 21:11:00 -0700 Subject: [PATCH 088/110] feat: add roboplan oink kinematics --- dimos/manipulation/planning/factory.py | 23 +- .../planning/kinematics/config.py | 25 +- .../planning/kinematics/test_pink_ik.py | 38 +- .../planning/world/roboplan_world.py | 533 +++++++++++++++++- dimos/manipulation/test_planning_factory.py | 36 +- dimos/manipulation/test_roboplan_world.py | 300 +++++++++- .../roboplan-oink-kinematics/.openspec.yaml | 2 - .../roboplan-oink-kinematics/design.md | 112 ---- .../roboplan-oink-kinematics/proposal.md | 41 -- .../changes/roboplan-oink-kinematics/tasks.md | 36 -- .../specs/roboplan-oink-kinematics/spec.md | 6 +- 11 files changed, 948 insertions(+), 204 deletions(-) delete mode 100644 openspec/changes/roboplan-oink-kinematics/.openspec.yaml delete mode 100644 openspec/changes/roboplan-oink-kinematics/design.md delete mode 100644 openspec/changes/roboplan-oink-kinematics/proposal.md delete mode 100644 openspec/changes/roboplan-oink-kinematics/tasks.md rename openspec/{changes/roboplan-oink-kinematics => }/specs/roboplan-oink-kinematics/spec.md (95%) diff --git a/dimos/manipulation/planning/factory.py b/dimos/manipulation/planning/factory.py index b37dcadea5..2bacdf1766 100644 --- a/dimos/manipulation/planning/factory.py +++ b/dimos/manipulation/planning/factory.py @@ -24,6 +24,7 @@ JacobianKinematicsConfig, ManipulationKinematicsConfig, PinkKinematicsConfig, + RoboPlanKinematicsConfig, kinematics_config_from_name, ) from dimos.manipulation.visualization.config import ( @@ -51,7 +52,7 @@ class PlanningSpecs: SUPPORTED_WORLD_BACKENDS = ("drake", "roboplan") SUPPORTED_PLANNERS = ("rrt_connect", "roboplan") -SUPPORTED_KINEMATICS = ("jacobian", "drake_optimization", "pink") +SUPPORTED_KINEMATICS = ("jacobian", "drake_optimization", "pink", "roboplan") def validate_backend_combination( @@ -76,6 +77,8 @@ def validate_backend_combination( raise ValueError('planner_name="roboplan" requires world_backend="roboplan"') if kinematics_name == "drake_optimization" and world_backend != "drake": raise ValueError('kinematics_name="drake_optimization" requires world_backend="drake"') + if kinematics_name == "roboplan" and world_backend != "roboplan": + raise ValueError('kinematics_name="roboplan" requires world_backend="roboplan"') def create_world( @@ -102,6 +105,8 @@ def create_world( def create_kinematics( name: str = "pink", config: ManipulationKinematicsConfig | None = None, + world: WorldSpec | None = None, + world_backend: str | None = None, **kwargs: Any, ) -> KinematicsSpec: """Create IK solver from a backend name or typed kinematics config.""" @@ -122,6 +127,16 @@ def create_kinematics( from dimos.manipulation.planning.kinematics.pink_ik import PinkIK return PinkIK(config, **kwargs) + elif isinstance(config, RoboPlanKinematicsConfig): + if world_backend != "roboplan" or world is None: + raise ValueError('kinematics_name="roboplan" requires world_backend="roboplan"') + configure_kinematics = getattr(world, "configure_kinematics", None) + if configure_kinematics is None: + raise ValueError( + "RoboPlan kinematics requires a RoboPlan world with configure_kinematics(...)" + ) + configure_kinematics(config) + return cast("KinematicsSpec", world) else: raise TypeError(f"Unsupported kinematics config: {type(config).__name__}") @@ -176,7 +191,11 @@ def create_planning_specs( return PlanningSpecs( world_monitor=WorldMonitor(world=world), - kinematics=create_kinematics(config=kinematics), + kinematics=create_kinematics( + config=kinematics, + world=world, + world_backend=world_backend, + ), planner=create_planner(name=planner_name, world=world, world_backend=world_backend), ) diff --git a/dimos/manipulation/planning/kinematics/config.py b/dimos/manipulation/planning/kinematics/config.py index 97835a6484..9d57a3f368 100644 --- a/dimos/manipulation/planning/kinematics/config.py +++ b/dimos/manipulation/planning/kinematics/config.py @@ -51,8 +51,26 @@ class PinkKinematicsConfig(BaseConfig): safety_break: bool = True +class RoboPlanKinematicsConfig(BaseConfig): + """Configuration for RoboPlan Oink task IK.""" + + backend: Literal["roboplan"] = "roboplan" + max_iterations: int = 100 + dt: float = 0.05 + position_cost: float = 1.0 + orientation_cost: float = 1.0 + task_gain: float = 0.5 + lm_damping: float = 1e-6 + regularization: float = 1e-8 + velocity_limit: float | None = None + collision_check: bool = True + + ManipulationKinematicsConfig = Annotated[ - JacobianKinematicsConfig | DrakeOptimizationKinematicsConfig | PinkKinematicsConfig, + JacobianKinematicsConfig + | DrakeOptimizationKinematicsConfig + | PinkKinematicsConfig + | RoboPlanKinematicsConfig, Field(discriminator="backend"), ] @@ -65,6 +83,9 @@ def kinematics_config_from_name(name: str) -> ManipulationKinematicsConfig: return DrakeOptimizationKinematicsConfig() if name == "pink": return PinkKinematicsConfig() + if name == "roboplan": + return RoboPlanKinematicsConfig() raise ValueError( - f"Unknown kinematics solver: {name}. Available: ['jacobian', 'drake_optimization', 'pink']" + "Unknown kinematics solver: " + f"{name}. Available: ['jacobian', 'drake_optimization', 'pink', 'roboplan']" ) diff --git a/dimos/manipulation/planning/kinematics/test_pink_ik.py b/dimos/manipulation/planning/kinematics/test_pink_ik.py index df1f19738f..553b3dc4d1 100644 --- a/dimos/manipulation/planning/kinematics/test_pink_ik.py +++ b/dimos/manipulation/planning/kinematics/test_pink_ik.py @@ -22,12 +22,18 @@ from typing import Any, cast import numpy as np +from pydantic import TypeAdapter import pytest from dimos.manipulation.planning.factory import create_kinematics from dimos.manipulation.planning.groups.models import PlanningGroup from dimos.manipulation.planning.kinematics import pink_ik as pink_ik_module -from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig +from dimos.manipulation.planning.kinematics.config import ( + ManipulationKinematicsConfig, + PinkKinematicsConfig, + RoboPlanKinematicsConfig, + kinematics_config_from_name, +) from dimos.manipulation.planning.kinematics.pink_ik import ( PinkIK, PinkIKConfig, @@ -48,6 +54,36 @@ from dimos.msgs.sensor_msgs.JointState import JointState +def test_roboplan_kinematics_config_defaults_and_parsing() -> None: + config = RoboPlanKinematicsConfig() + + assert config.backend == "roboplan" + assert config.max_iterations == 100 + assert config.dt == pytest.approx(0.05) + assert config.position_cost == pytest.approx(1.0) + assert config.orientation_cost == pytest.approx(1.0) + assert config.task_gain == pytest.approx(0.5) + assert config.lm_damping == pytest.approx(1e-6) + assert config.regularization == pytest.approx(1e-8) + assert config.velocity_limit is None + assert config.collision_check is True + + parsed = TypeAdapter(ManipulationKinematicsConfig).validate_python( + { + "backend": "roboplan", + "max_iterations": 5, + "velocity_limit": 0.25, + "collision_check": False, + } + ) + + assert isinstance(parsed, RoboPlanKinematicsConfig) + assert parsed.max_iterations == 5 + assert parsed.velocity_limit == pytest.approx(0.25) + assert parsed.collision_check is False + assert isinstance(kinematics_config_from_name("roboplan"), RoboPlanKinematicsConfig) + + class _FakeJoint: def __init__(self, idx_q: int) -> None: self.idx_q = idx_q diff --git a/dimos/manipulation/planning/world/roboplan_world.py b/dimos/manipulation/planning/world/roboplan_world.py index a2739afc99..87369260ad 100644 --- a/dimos/manipulation/planning/world/roboplan_world.py +++ b/dimos/manipulation/planning/world/roboplan_world.py @@ -21,10 +21,11 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping, Sequence from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass, field, replace +import importlib from itertools import combinations, pairwise from math import atan2, sqrt from pathlib import Path @@ -45,11 +46,14 @@ "Install the manipulation extra before selecting the roboplan backend." ) from exc +from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry from dimos.manipulation.planning.groups.utils import joint_target_to_global_names +from dimos.manipulation.planning.kinematics.config import RoboPlanKinematicsConfig from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.manipulation.planning.spec.enums import ObstacleType, PlanningStatus -from dimos.manipulation.planning.spec.models import Obstacle, PlanningResult, WorldRobotID +from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType, PlanningStatus +from dimos.manipulation.planning.spec.models import IKResult, Obstacle, PlanningResult, WorldRobotID +from dimos.manipulation.planning.utils.kinematics_utils import compute_pose_error from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake from dimos.manipulation.planning.utils.path_utils import compute_path_length from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -62,7 +66,6 @@ from numpy.typing import NDArray - from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.models import PlanningGroupID, RobotName logger = setup_logger() @@ -156,6 +159,7 @@ def __init__( ] = {} self._full_native_joint_names: tuple[str, ...] = () self._uses_composite_model = False + self._kinematics_config = RoboPlanKinematicsConfig() # Robot Management @@ -422,6 +426,179 @@ def get_group_jacobian( group, robot.config.joint_names, native_joint_names, arr ) + # KinematicsSpec for RoboPlan Oink IK + + def configure_kinematics( + self, config: RoboPlanKinematicsConfig | None = None, **overrides: Any + ) -> None: + """Configure RoboPlan Oink IK behavior for this world instance.""" + config_values = (config or RoboPlanKinematicsConfig()).model_dump() + config_values.update(overrides) + self._kinematics_config = RoboPlanKinematicsConfig(**config_values) + + def solve( + self, + world: Any, + robot_id: WorldRobotID, + target_pose: PoseStamped, + seed: JointState | None = None, + position_tolerance: float = 0.001, + orientation_tolerance: float = 0.01, + max_attempts: int = 10, + ) -> IKResult: + """Solve a single pose target with RoboPlan Oink IK.""" + if world is not self: + return self._ik_failure( + IKStatus.NO_SOLUTION, + "RoboPlan IK requires its RoboPlanWorld instance", + ) + try: + robot = self._get_robot(robot_id) + group_id = self._planning_groups.primary_pose_group_id_for_robot(robot.config.name) + if group_id is None: + return self._ik_failure( + IKStatus.NO_SOLUTION, + f"Robot '{robot.config.name}' has no pose-targetable planning group", + ) + group = self._planning_groups.get(group_id) + except (KeyError, ValueError) as exc: + return self._ik_failure(IKStatus.NO_SOLUTION, str(exc)) + return self.solve_pose_targets( + world=self, + pose_targets={group: target_pose}, + seed=seed, + position_tolerance=position_tolerance, + orientation_tolerance=orientation_tolerance, + max_attempts=max_attempts, + ) + + def solve_pose_targets( + self, + world: Any, + pose_targets: Mapping[PlanningGroup, PoseStamped], + auxiliary_groups: Sequence[PlanningGroup] = (), + seed: JointState | None = None, + position_tolerance: float = 0.001, + orientation_tolerance: float = 0.01, + max_attempts: int = 10, + ) -> IKResult: + """Solve one or more planning-group pose targets with RoboPlan Oink IK.""" + if world is not self: + return self._ik_failure( + IKStatus.NO_SOLUTION, + "RoboPlan IK requires its RoboPlanWorld instance", + ) + try: + self._require_finalized() + except RuntimeError as exc: + return self._ik_failure(IKStatus.NO_SOLUTION, str(exc)) + if not pose_targets: + return self._ik_failure(IKStatus.NO_SOLUTION, "At least one pose target is required") + + pose_groups = tuple(pose_targets.keys()) + for group in pose_groups: + if not group.has_pose_target or group.tip_link is None: + return self._ik_failure( + IKStatus.NO_SOLUTION, + f"Planning group '{group.id}' has no pose target frame", + ) + + try: + selection = PlanningGroupSelection.from_groups(pose_groups + tuple(auxiliary_groups)) + except ValueError as exc: + return self._ik_failure(IKStatus.NO_SOLUTION, str(exc)) + unsupported = self._validate_supported_selection(selection) + if unsupported is not None: + return self._ik_failure(IKStatus.NO_SOLUTION, unsupported.message) + + try: + optimal_ik = self._load_optimal_ik() + group_data = self._group_data_for_selection_ids(selection.group_ids) + scene = self._require_scene() + oink = optimal_ik.Oink(scene, group_name=group_data.group_name) + tasks = self._make_oink_frame_tasks( + optimal_ik, + oink, + scene, + pose_targets, + position_tolerance, + orientation_tolerance, + ) + constraints = self._make_oink_constraints(optimal_ik, oink, group_data) + candidate_native_q = self._seed_native_selection_q(selection, group_data, seed) + lower_limits, upper_limits = self._selection_native_limits(selection, group_data) + except (ImportError, KeyError, ValueError, AttributeError) as exc: + return self._ik_failure(IKStatus.NO_SOLUTION, f"RoboPlan Oink IK setup failed: {exc}") + + final_position_error = float("inf") + final_orientation_error = float("inf") + iteration_limit = max(1, self._kinematics_config.max_iterations) + if max_attempts <= 0: + return self._ik_failure(IKStatus.NO_SOLUTION, "max_attempts must be positive") + + try: + with self.scratch_context() as ctx: + for iteration in range(iteration_limit): + result = self._maybe_return_converged_oink_result( + ctx, + selection, + group_data, + candidate_native_q, + pose_targets, + position_tolerance, + orientation_tolerance, + iteration, + ) + final_position_error = result.position_error + final_orientation_error = result.orientation_error + if result.is_success(): + return result + if result.status == IKStatus.COLLISION: + return result + + full_q = self._full_scene_q_with_native_selection_q( + ctx, group_data, candidate_native_q + ) + self._set_scene_joint_positions(scene, full_q) + delta_q = self._solve_oink_delta(oink, group_data, scene, tasks, constraints) + delta_full_q = self._scatter_oink_delta(oink, group_data, delta_q) + next_full_q = self._integrate_scene_q(scene, full_q, delta_full_q) + self._set_scene_joint_positions(scene, next_full_q) + candidate_native_q = self._native_selection_q_from_full_q( + group_data, next_full_q + ) + candidate_native_q = np.clip(candidate_native_q, lower_limits, upper_limits) + + result = self._maybe_return_converged_oink_result( + ctx, + selection, + group_data, + candidate_native_q, + pose_targets, + position_tolerance, + orientation_tolerance, + iteration_limit, + ) + final_position_error = result.position_error + final_orientation_error = result.orientation_error + if result.is_success(): + return result + if result.status == IKStatus.COLLISION: + return result + except ValueError as exc: + return self._ik_failure(IKStatus.NO_SOLUTION, f"RoboPlan Oink IK mapping failed: {exc}") + except Exception as exc: + return self._ik_failure(IKStatus.NO_SOLUTION, f"RoboPlan Oink IK failed: {exc}") + + return IKResult( + status=IKStatus.NO_SOLUTION, + joint_state=None, + position_error=final_position_error, + orientation_error=final_orientation_error, + iterations=iteration_limit, + message="RoboPlan Oink IK did not converge within the iteration budget", + ) + # PlannerSpec for native RoboPlan planning def plan_selected_joint_path( @@ -497,6 +674,354 @@ def get_name(self) -> str: # Internals + def _ik_failure( + self, + status: IKStatus, + message: str, + position_error: float = 0.0, + orientation_error: float = 0.0, + iterations: int = 0, + ) -> IKResult: + return IKResult( + status=status, + joint_state=None, + position_error=position_error, + orientation_error=orientation_error, + iterations=iterations, + message=message, + ) + + def _load_optimal_ik(self) -> Any: + try: + return importlib.import_module("roboplan.optimal_ik") + except ImportError as exc: + raise ImportError( + "RoboPlan Oink IK requires roboplan.optimal_ik. " + "Install a RoboPlan build that includes the optimal_ik Python bindings." + ) from exc + + def _make_oink_frame_tasks( + self, + optimal_ik: Any, + oink: Any, + scene: Any, + pose_targets: Mapping[PlanningGroup, PoseStamped], + position_tolerance: float, + orientation_tolerance: float, + ) -> list[Any]: + tasks: list[Any] = [] + for group, target_pose in pose_targets.items(): + if group.tip_link is None: + raise ValueError(f"Planning group '{group.id}' has no pose target frame") + native_map = self._native_names_by_robot[group.robot_name] + target = roboplan_core.CartesianConfiguration() + target.base_frame = native_map.link(group.base_link) + target.tip_frame = native_map.link(group.tip_link) + target.tform = pose_to_matrix(target_pose) + options = self._make_oink_frame_task_options( + optimal_ik, + position_tolerance, + orientation_tolerance, + ) + tasks.append(optimal_ik.FrameTask(oink, scene, target, options)) + return tasks + + def _make_oink_frame_task_options( + self, + optimal_ik: Any, + position_tolerance: float, + orientation_tolerance: float, + ) -> Any: + values = { + "position_cost": self._kinematics_config.position_cost, + "orientation_cost": self._kinematics_config.orientation_cost, + "task_gain": self._kinematics_config.task_gain, + "lm_damping": self._kinematics_config.lm_damping, + "max_position_error": position_tolerance, + "max_rotation_error": orientation_tolerance, + } + try: + return optimal_ik.FrameTaskOptions(**values) + except TypeError: + options = optimal_ik.FrameTaskOptions() + for name, value in values.items(): + setattr(options, name, value) + return options + + def _make_oink_constraints( + self, optimal_ik: Any, oink: Any, group_data: _RoboPlanGroupData + ) -> list[Any]: + constraints = [optimal_ik.PositionLimit(oink, gain=1.0)] + if self._kinematics_config.velocity_limit is not None: + v_max = np.full( + self._oink_variable_count(oink, group_data), + self._kinematics_config.velocity_limit, + dtype=np.float64, + ) + constraints.append( + optimal_ik.VelocityLimit(oink, self._kinematics_config.dt, v_max=v_max) + ) + return constraints + + def _oink_variable_count(self, oink: Any, group_data: _RoboPlanGroupData) -> int: + v_indices = getattr(oink, "v_indices", None) + if v_indices is None: + return len(group_data.native_joint_names) + return len(tuple(v_indices)) + + def _seed_native_selection_q( + self, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + seed: JointState | None, + ) -> NDArray[np.float64]: + positions_by_global = self._selection_belief_positions(selection) + if seed is not None: + self._overlay_seed_positions(selection, positions_by_global, seed) + return np.asarray( + [ + positions_by_global[group_data.native_to_global_joint_name[native_name]] + for native_name in group_data.native_joint_names + ], + dtype=np.float64, + ) + + def _overlay_seed_positions( + self, + selection: PlanningGroupSelection, + positions_by_global: dict[str, float], + seed: JointState, + ) -> None: + if not seed.name: + if len(seed.position) != len(selection.joint_names): + raise ValueError( + f"Seed has {len(seed.position)} positions for selection, " + f"expected {len(selection.joint_names)}" + ) + for global_name, position in zip(selection.joint_names, seed.position, strict=True): + positions_by_global[global_name] = float(position) + return + if len(seed.name) != len(seed.position): + raise ValueError(f"Seed has {len(seed.name)} names but {len(seed.position)} positions") + + local_to_global: dict[str, str] = {} + for group in selection.groups: + for local_name, global_name in zip( + group.local_joint_names, group.joint_names, strict=True + ): + local_to_global[local_name] = global_name + local_to_global[f"{group.robot_name}/{local_name}"] = global_name + + seen: set[str] = set() + for seed_name, position in zip(seed.name, seed.position, strict=True): + if seed_name in selection.joint_names: + global_name = seed_name + elif seed_name in local_to_global: + global_name = local_to_global[seed_name] + else: + raise ValueError(f"Seed contains joint outside RoboPlan selection: {seed_name}") + if global_name in seen: + raise ValueError(f"Seed contains duplicate selected joint: {global_name}") + seen.add(global_name) + positions_by_global[global_name] = float(position) + + def _selection_native_limits( + self, selection: PlanningGroupSelection, group_data: _RoboPlanGroupData + ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + lower_by_global: dict[str, float] = {} + upper_by_global: dict[str, float] = {} + for group in selection.groups: + robot_id = self._robot_ids_by_name[group.robot_name] + robot = self._get_robot(robot_id) + if robot.lower_limits is None or robot.upper_limits is None: + raise ValueError(f"RoboPlan joint limits are unavailable for robot '{robot_id}'") + index_by_local = {name: index for index, name in enumerate(robot.config.joint_names)} + for local_name, global_name in zip( + group.local_joint_names, group.joint_names, strict=True + ): + index = index_by_local[local_name] + lower_by_global[global_name] = float(robot.lower_limits[index]) + upper_by_global[global_name] = float(robot.upper_limits[index]) + return ( + np.asarray( + [ + lower_by_global[group_data.native_to_global_joint_name[native_name]] + for native_name in group_data.native_joint_names + ], + dtype=np.float64, + ), + np.asarray( + [ + upper_by_global[group_data.native_to_global_joint_name[native_name]] + for native_name in group_data.native_joint_names + ], + dtype=np.float64, + ), + ) + + def _maybe_return_converged_oink_result( + self, + ctx: RoboPlanContext, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + native_q: NDArray[np.float64], + pose_targets: Mapping[PlanningGroup, PoseStamped], + position_tolerance: float, + orientation_tolerance: float, + iterations: int, + ) -> IKResult: + joint_state = self._native_selection_q_to_joint_state(selection, group_data, native_q) + self._set_selection_state(ctx, selection, joint_state) + position_error, orientation_error = self._pose_target_errors(ctx, pose_targets) + if position_error > position_tolerance or orientation_error > orientation_tolerance: + return IKResult( + status=IKStatus.NO_SOLUTION, + joint_state=None, + position_error=position_error, + orientation_error=orientation_error, + iterations=iterations, + message="RoboPlan Oink IK candidate has not converged", + ) + if self._kinematics_config.collision_check and not self._selection_config_collision_free( + selection, joint_state + ): + return IKResult( + status=IKStatus.COLLISION, + joint_state=None, + position_error=position_error, + orientation_error=orientation_error, + iterations=iterations, + message="RoboPlan Oink IK converged to a colliding configuration", + ) + return IKResult( + status=IKStatus.SUCCESS, + joint_state=joint_state, + position_error=position_error, + orientation_error=orientation_error, + iterations=iterations, + message="RoboPlan Oink IK solution found", + ) + + def _native_selection_q_to_joint_state( + self, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + native_q: NDArray[np.float64], + ) -> JointState: + if len(native_q) != len(group_data.native_joint_names): + raise ValueError( + f"RoboPlan Oink returned {len(native_q)} selected positions, " + f"expected {len(group_data.native_joint_names)}" + ) + positions_by_global = { + group_data.native_to_global_joint_name[native_name]: float(position) + for native_name, position in zip(group_data.native_joint_names, native_q, strict=True) + } + return JointState( + { + "name": list(selection.joint_names), + "position": [positions_by_global[name] for name in selection.joint_names], + } + ) + + def _pose_target_errors( + self, ctx: RoboPlanContext, pose_targets: Mapping[PlanningGroup, PoseStamped] + ) -> tuple[float, float]: + position_errors: list[float] = [] + orientation_errors: list[float] = [] + for group, target_pose in pose_targets.items(): + current_pose = self.get_group_ee_pose(ctx, group.id) + position_error, orientation_error = compute_pose_error( + pose_to_matrix(current_pose), pose_to_matrix(target_pose) + ) + position_errors.append(position_error) + orientation_errors.append(orientation_error) + return max(position_errors), max(orientation_errors) + + def _full_scene_q_with_native_selection_q( + self, + ctx: RoboPlanContext, + group_data: _RoboPlanGroupData, + native_q: NDArray[np.float64], + ) -> NDArray[np.float64]: + if len(native_q) != len(group_data.native_joint_names): + raise ValueError( + f"Selected q length {len(native_q)} does not match " + f"{len(group_data.native_joint_names)} native joints" + ) + full_q = self._full_scene_q(ctx) + index_by_native = {name: index for index, name in enumerate(self._full_native_joint_names)} + for native_name, position in zip(group_data.native_joint_names, native_q, strict=True): + full_q[index_by_native[native_name]] = float(position) + return full_q + + def _set_scene_joint_positions(self, scene: Any, q: NDArray[np.float64]) -> None: + setter = getattr(scene, "setJointPositions", None) + if setter is not None: + setter(np.asarray(q, dtype=np.float64)) + + def _solve_oink_delta( + self, + oink: Any, + group_data: _RoboPlanGroupData, + scene: Any, + tasks: Sequence[Any], + constraints: Sequence[Any], + ) -> NDArray[np.float64]: + delta_q = np.zeros(self._oink_variable_count(oink, group_data), dtype=np.float64) + oink.solveIk( + scene, + list(tasks), + list(constraints), + [], + delta_q, + regularization=self._kinematics_config.regularization, + ) + return delta_q + + def _scatter_oink_delta( + self, oink: Any, group_data: _RoboPlanGroupData, delta_q: NDArray[np.float64] + ) -> NDArray[np.float64]: + delta = np.asarray(delta_q, dtype=np.float64) + delta_full = np.zeros(len(self._full_native_joint_names), dtype=np.float64) + v_indices = getattr(oink, "v_indices", None) + if v_indices is not None: + indices = np.asarray(tuple(v_indices), dtype=np.int64) + if len(indices) != len(delta): + raise ValueError( + f"Oink returned delta length {len(delta)} for {len(indices)} velocity indices" + ) + delta_full[indices] = delta + return delta_full + if len(delta) == len(delta_full): + return delta + if len(delta) != len(group_data.native_joint_names): + raise ValueError( + f"Oink returned delta length {len(delta)}, expected full scene length " + f"{len(delta_full)} or selected length {len(group_data.native_joint_names)}" + ) + index_by_native = {name: index for index, name in enumerate(self._full_native_joint_names)} + for native_name, value in zip(group_data.native_joint_names, delta, strict=True): + delta_full[index_by_native[native_name]] = float(value) + return delta_full + + def _integrate_scene_q( + self, scene: Any, q: NDArray[np.float64], delta_q: NDArray[np.float64] + ) -> NDArray[np.float64]: + integrator = getattr(scene, "integrate", None) + if integrator is not None: + return np.asarray(integrator(q, delta_q), dtype=np.float64) + return np.asarray(q, dtype=np.float64) + np.asarray(delta_q, dtype=np.float64) + + def _native_selection_q_from_full_q( + self, group_data: _RoboPlanGroupData, full_q: NDArray[np.float64] + ) -> NDArray[np.float64]: + index_by_native = {name: index for index, name in enumerate(self._full_native_joint_names)} + return np.asarray( + [full_q[index_by_native[native_name]] for native_name in group_data.native_joint_names], + dtype=np.float64, + ) + def _create_scene(self) -> Any: if self._uses_composite_model: urdf_path, srdf_path, package_paths = self._prepare_composite_model() diff --git a/dimos/manipulation/test_planning_factory.py b/dimos/manipulation/test_planning_factory.py index 10ca903d1c..27e4e0a260 100644 --- a/dimos/manipulation/test_planning_factory.py +++ b/dimos/manipulation/test_planning_factory.py @@ -30,7 +30,10 @@ create_world, validate_backend_combination, ) -from dimos.manipulation.planning.kinematics.config import JacobianKinematicsConfig +from dimos.manipulation.planning.kinematics.config import ( + JacobianKinematicsConfig, + RoboPlanKinematicsConfig, +) from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -84,6 +87,11 @@ def test_validate_backend_combination_rejects_invalid_combinations(): ): validate_backend_combination(world_backend="roboplan", kinematics_name="drake_optimization") + with pytest.raises( + ValueError, match='kinematics_name="roboplan" requires world_backend="roboplan"' + ): + validate_backend_combination(world_backend="drake", kinematics_name="roboplan") + def test_create_planner_uses_roboplan_world_as_native_planner(mocker: MockerFixture): world = mocker.MagicMock() @@ -99,6 +107,28 @@ def test_create_planner_rejects_roboplan_without_roboplan_world(mocker: MockerFi create_planner(name="roboplan", world=mocker.MagicMock(), world_backend="drake") +def test_create_kinematics_uses_roboplan_world_as_native_solver(mocker: MockerFixture): + world = mocker.MagicMock() + config = RoboPlanKinematicsConfig(max_iterations=7) + + assert create_kinematics(config=config, world=world, world_backend="roboplan") is world + world.configure_kinematics.assert_called_once_with(config) + + +def test_create_kinematics_rejects_roboplan_without_roboplan_world(mocker: MockerFixture): + with pytest.raises( + ValueError, match='kinematics_name="roboplan" requires world_backend="roboplan"' + ): + create_kinematics( + config=RoboPlanKinematicsConfig(), world=mocker.MagicMock(), world_backend="drake" + ) + + world = mocker.MagicMock() + del world.configure_kinematics + with pytest.raises(ValueError, match="requires a RoboPlan world"): + create_kinematics(config=RoboPlanKinematicsConfig(), world=world, world_backend="roboplan") + + def test_create_planning_stack_wires_selected_components( mocker: MockerFixture, robot_config: RobotModelConfig ): @@ -129,7 +159,9 @@ def test_create_planning_stack_wires_selected_components( assert result == (world, kinematics, planner, "robot-id") mock_world.assert_called_once_with(backend="drake", visualization=None) - mock_kinematics.assert_called_once_with(config=JacobianKinematicsConfig()) + mock_kinematics.assert_called_once_with( + config=JacobianKinematicsConfig(), world=world, world_backend="drake" + ) mock_planner.assert_called_once_with(name="rrt_connect", world=world, world_backend="drake") world.add_robot.assert_called_once_with(robot_config) world.finalize.assert_called_once() diff --git a/dimos/manipulation/test_roboplan_world.py b/dimos/manipulation/test_roboplan_world.py index 8959cd49e3..14ed635c7d 100644 --- a/dimos/manipulation/test_roboplan_world.py +++ b/dimos/manipulation/test_roboplan_world.py @@ -16,6 +16,7 @@ from __future__ import annotations +from dataclasses import replace import importlib from pathlib import Path import sys @@ -30,8 +31,9 @@ PlanningGroupDefinition, PlanningGroupSelection, ) +from dimos.manipulation.planning.kinematics.config import RoboPlanKinematicsConfig from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.manipulation.planning.spec.enums import ObstacleType, PlanningStatus +from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType, PlanningStatus from dimos.manipulation.planning.spec.models import Obstacle from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion @@ -174,12 +176,85 @@ def plan( ) +class FakeCartesianConfiguration: + def __init__(self) -> None: + self.base_frame = "" + self.tip_frame = "" + self.tform = np.eye(4) + + +class FakeFrameTaskOptions: + def __init__(self, **kwargs: float) -> None: + for name, value in kwargs.items(): + setattr(self, name, value) + + +class FakeFrameTask: + instances: ClassVar[list[FakeFrameTask]] = [] + + def __init__( + self, + oink: FakeOink, + scene: FakeScene, + target: FakeCartesianConfiguration, + options: FakeFrameTaskOptions, + ) -> None: + self.oink = oink + self.scene = scene + self.target = target + self.options = options + self.instances.append(self) + + +class FakePositionLimit: + def __init__(self, oink: FakeOink, gain: float) -> None: + self.oink = oink + self.gain = gain + + +class FakeVelocityLimit: + instances: ClassVar[list[FakeVelocityLimit]] = [] + + def __init__(self, oink: FakeOink, dt: float, v_max: np.ndarray) -> None: + self.oink = oink + self.dt = dt + self.v_max = v_max + self.instances.append(self) + + +class FakeOink: + last_instance: ClassVar[FakeOink | None] = None + + def __init__(self, scene: FakeScene, group_name: str) -> None: + self.scene = scene + self.group_name = group_name + self.v_indices = tuple( + range(len(scene.joint_group_joint_names or next(iter(scene.joint_groups.values())))) + ) + self.solve_calls: list[tuple[list[FakeFrameTask], list[Any], float]] = [] + FakeOink.last_instance = self + + def solveIk( + self, + scene: FakeScene, + tasks: list[FakeFrameTask], + constraints: list[Any], + barriers: list[Any], + delta_q: np.ndarray, + regularization: float, + ) -> None: + _ = (scene, barriers) + self.solve_calls.append((tasks, constraints, regularization)) + delta_q[:] = 0.25 + + def _install_fake_roboplan(monkeypatch: pytest.MonkeyPatch) -> None: roboplan_pkg = ModuleType("roboplan") roboplan_pkg.__path__ = [] # type: ignore[attr-defined] core = ModuleType("roboplan.core") core.Scene = FakeScene # type: ignore[attr-defined] core.JointConfiguration = FakeJointConfiguration # type: ignore[attr-defined] + core.CartesianConfiguration = FakeCartesianConfiguration # type: ignore[attr-defined] def has_collisions_along_path( scene: FakeScene, @@ -201,9 +276,21 @@ def has_collisions_along_path( rrt.RRTOptions = FakeRRTOptions # type: ignore[attr-defined] rrt.RRT = FakeRRT # type: ignore[attr-defined] + optimal_ik = ModuleType("roboplan.optimal_ik") + optimal_ik.Oink = FakeOink # type: ignore[attr-defined] + optimal_ik.FrameTaskOptions = FakeFrameTaskOptions # type: ignore[attr-defined] + optimal_ik.FrameTask = FakeFrameTask # type: ignore[attr-defined] + optimal_ik.PositionLimit = FakePositionLimit # type: ignore[attr-defined] + optimal_ik.VelocityLimit = FakeVelocityLimit # type: ignore[attr-defined] + + FakeFrameTask.instances = [] + FakeVelocityLimit.instances = [] + FakeOink.last_instance = None + monkeypatch.setitem(sys.modules, "roboplan", roboplan_pkg) monkeypatch.setitem(sys.modules, "roboplan.core", core) monkeypatch.setitem(sys.modules, "roboplan.rrt", rrt) + monkeypatch.setitem(sys.modules, "roboplan.optimal_ik", optimal_ik) @pytest.fixture @@ -881,6 +968,217 @@ def test_selected_native_planner_rejects_non_matching_selection( assert "exactly match" in result.message +def test_roboplan_oink_builds_tasks_and_solves_with_seed_order( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.configure_kinematics( + RoboPlanKinematicsConfig( + max_iterations=2, + position_cost=3.0, + orientation_cost=4.0, + task_gain=0.75, + lm_damping=1e-5, + velocity_limit=0.5, + collision_check=False, + ) + ) + world.finalize() + world.sync_from_joint_state( + robot_id, JointState(name=["joint1", "joint2"], position=[0.1, 0.2]) + ) + target = PoseStamped(position=Vector3(x=1.7), orientation=Quaternion()) # type: ignore[call-arg] + + result = world.solve( + world, + robot_id, + target, + seed=JointState(name=["arm/joint2", "arm/joint1"], position=[0.4, 0.3]), + position_tolerance=0.01, + orientation_tolerance=0.02, + ) + + assert result.status == IKStatus.SUCCESS + assert result.joint_state is not None + assert result.joint_state.name == ["arm/joint1", "arm/joint2"] + assert result.joint_state.position == pytest.approx([0.8, 0.9]) + assert len(FakeFrameTask.instances) == 1 + task = FakeFrameTask.instances[0] + assert task.target.base_frame == "base_link" + assert task.target.tip_frame == "tcp" + np.testing.assert_allclose(task.target.tform, pose_to_matrix(target)) + assert task.options.position_cost == pytest.approx(3.0) + assert task.options.orientation_cost == pytest.approx(4.0) + assert task.options.task_gain == pytest.approx(0.75) + assert task.options.lm_damping == pytest.approx(1e-5) + assert task.options.max_position_error == pytest.approx(0.01) + assert task.options.max_rotation_error == pytest.approx(0.02) + assert FakeOink.last_instance is not None + assert FakeOink.last_instance.group_name == "manipulator" + assert len(FakeVelocityLimit.instances) == 1 + assert FakeVelocityLimit.instances[0].dt == pytest.approx(0.05) + np.testing.assert_allclose(FakeVelocityLimit.instances[0].v_max, [0.5, 0.5]) + + +def test_roboplan_oink_multi_target_retains_auxiliary_group( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld + + second_model = Path(robot_config.model_path).with_name("robot2.urdf") + second_model.write_text(Path(robot_config.model_path).read_text()) + right_config = robot_config.model_copy(update={"name": "right_arm", "model_path": second_model}) + world = RoboPlanWorld() + left_id = world.add_robot(robot_config) + right_id = world.add_robot(right_config) + world.configure_kinematics(max_iterations=2, collision_check=False) + world.finalize() + world.sync_from_joint_state(left_id, JointState(name=[], position=[0.1, 0.2])) + world.sync_from_joint_state(right_id, JointState(name=[], position=[0.3, 0.4])) + left_group = world._planning_groups.get("arm/manipulator") + right_group = world._planning_groups.get("right_arm/manipulator") + target_left = PoseStamped(position=Vector3(x=3.0), orientation=Quaternion()) # type: ignore[call-arg] + target_right = PoseStamped(position=Vector3(x=3.0), orientation=Quaternion()) # type: ignore[call-arg] + + result = world.solve_pose_targets( + world, + {left_group: target_left, right_group: target_right}, + seed=JointState( + name=["right_arm/joint1", "right_arm/joint2", "arm/joint1", "arm/joint2"], + position=[0.3, 0.4, 0.1, 0.2], + ), + ) + + assert result.status == IKStatus.NO_SOLUTION + assert "did not converge" in result.message + assert len(FakeFrameTask.instances) == 2 + assert FakeOink.last_instance is not None + assert ( + FakeOink.last_instance.group_name + == "_dimos_composite__arm_manipulator__right_arm_manipulator" + ) + + +def test_roboplan_oink_uses_current_state_when_seed_omits_selected_joints( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.configure_kinematics(max_iterations=1, collision_check=False) + world.finalize() + world.sync_from_joint_state(robot_id, JointState(name=[], position=[0.2, 0.3])) + group = world._planning_groups.get("arm/manipulator") + target = PoseStamped(position=Vector3(x=1.3), orientation=Quaternion()) # type: ignore[call-arg] + + result = world.solve_pose_targets( + world, + {group: target}, + seed=JointState(name=["arm/joint1"], position=[0.5]), + ) + + assert result.status == IKStatus.SUCCESS + assert result.joint_state is not None + assert result.joint_state.position == pytest.approx([0.75, 0.55]) + + +@pytest.mark.parametrize( + ("call", "message"), + [ + ("empty", "At least one pose target"), + ("missing_frame", "has no pose target frame"), + ("overlap", "overlap"), + ], +) +def test_roboplan_oink_rejects_invalid_targets( + fake_roboplan: None, robot_config: RobotModelConfig, call: str, message: str +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + group = world._planning_groups.get("arm/manipulator") + target = PoseStamped(position=Vector3(), orientation=Quaternion()) # type: ignore[call-arg] + + if call == "empty": + result = world.solve_pose_targets(world, {}) + elif call == "missing_frame": + no_tip = replace(group, tip_link=None) + result = world.solve_pose_targets(world, {no_tip: target}) + else: + result = world.solve_pose_targets(world, {group: target}, auxiliary_groups=(group,)) + + assert result.status == IKStatus.NO_SOLUTION + assert message in result.message + + +def test_roboplan_oink_rejects_unsupported_composite_selection( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + config = robot_config.model_copy( + update={ + "planning_groups": [ + PlanningGroupDefinition( + name="arm", joint_names=("joint1",), base_link="base", tip_link="tcp" + ), + PlanningGroupDefinition( + name="wrist", joint_names=("joint2",), base_link="link1", tip_link="tcp" + ), + ] + } + ) + world, _robot_id = _make_world(fake_roboplan, config) + world.finalize() + arm = world._planning_groups.get("arm/arm") + missing_group = replace(world._planning_groups.get("arm/wrist"), id="arm/missing") + target = PoseStamped(position=Vector3(), orientation=Quaternion()) # type: ignore[call-arg] + + result = world.solve_pose_targets(world, {arm: target, missing_group: target}) + + assert result.status == IKStatus.NO_SOLUTION + assert "arm/missing" in result.message + + +def test_roboplan_oink_reports_missing_optimal_ik( + fake_roboplan: None, robot_config: RobotModelConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delitem(sys.modules, "roboplan.optimal_ik") + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + target = PoseStamped(position=Vector3(), orientation=Quaternion()) # type: ignore[call-arg] + + result = world.solve(world, robot_id, target) + + assert result.status == IKStatus.NO_SOLUTION + assert "requires roboplan.optimal_ik" in result.message + + +def test_roboplan_oink_reports_non_convergence( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.configure_kinematics(max_iterations=1, collision_check=False) + world.finalize() + world.sync_from_joint_state(robot_id, JointState(name=[], position=[0.0, 0.0])) + target = PoseStamped(position=Vector3(x=10.0), orientation=Quaternion()) # type: ignore[call-arg] + + result = world.solve(world, robot_id, target, position_tolerance=0.001) + + assert result.status == IKStatus.NO_SOLUTION + assert "did not converge" in result.message + + +def test_roboplan_oink_reports_colliding_final_candidate( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.configure_kinematics(max_iterations=1, collision_check=True) + world.finalize() + world.sync_from_joint_state(robot_id, JointState(name=[], position=[0.85, 0.0])) + target = PoseStamped(position=Vector3(x=1.25), orientation=Quaternion()) # type: ignore[call-arg] + + result = world.solve(world, robot_id, target, position_tolerance=0.01) + + assert result.status == IKStatus.COLLISION + assert "colliding configuration" in result.message + + def test_provided_srdf_path_is_passed_directly( fake_roboplan: None, robot_config: RobotModelConfig, tmp_path: Path ) -> None: diff --git a/openspec/changes/roboplan-oink-kinematics/.openspec.yaml b/openspec/changes/roboplan-oink-kinematics/.openspec.yaml deleted file mode 100644 index 578bd54974..0000000000 --- a/openspec/changes/roboplan-oink-kinematics/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-26 diff --git a/openspec/changes/roboplan-oink-kinematics/design.md b/openspec/changes/roboplan-oink-kinematics/design.md deleted file mode 100644 index 46eb83a7e5..0000000000 --- a/openspec/changes/roboplan-oink-kinematics/design.md +++ /dev/null @@ -1,112 +0,0 @@ -## Context - -The manipulation planning factory currently supports world backends `drake` and `roboplan`, planners `rrt_connect` and `roboplan`, and kinematics backends `jacobian`, `drake_optimization`, and `pink`. RoboPlan planning is already scene-coupled: `create_planner(name="roboplan", world=..., world_backend="roboplan")` returns the existing `RoboPlanWorld` object as `PlannerSpec`. - -`RoboPlanWorld` owns the RoboPlan `Scene`, live/scratch contexts, global/native joint-name maps, planning-group registry, generated composite groups, group FK/Jacobian helpers, selected-state overlays, joint limits, and collision checks. Those are also the exact inputs needed for RoboPlan-native IK. - -Robokin's `RoboPlanOinkKinematics` wrapper demonstrates the relevant RoboPlan API shape: construct `Oink(scene, group_name)`, create `FrameTask` objects from `CartesianConfiguration`, add `PositionLimit` and optional `VelocityLimit` constraints, call `oink.solveIk(...)`, integrate `delta_q` through the scene, and repeat toward the goal. Local bindings expose `roboplan.optimal_ik.Oink`, `FrameTask`, `FrameTaskOptions`, `PositionLimit`, and `VelocityLimit`. - -## Goals / Non-Goals - -**Goals:** - -- Add `backend="roboplan"` as a typed manipulation kinematics config. -- Keep `RoboPlanWorld` as the single RoboPlan integration object for world, planner, and kinematics roles. -- Implement `KinematicsSpec` on `RoboPlanWorld` using RoboPlan Oink task IK rather than a hand-written Jacobian pseudoinverse loop. -- Support multiple pose targets when the selected planning groups are non-overlapping and represented by one RoboPlan group or generated composite group. -- Preserve DimOS public global joint names at API boundaries. -- Return selected global joints in the `IKResult`, including auxiliary selected groups that retain seed/current positions. -- Fail clearly for unsupported selections, missing pose target frames, missing Oink bindings, or collision-invalid final states. - -**Non-Goals:** - -- Do not implement the deferred robokin backend. -- Do not split `RoboPlanWorld` into `RoboPlanPlanner` or `RoboPlanIK` adapter classes. -- Do not add a planner config unless RoboPlan planner tunables are needed separately. -- Do not promise global IK completeness or obstacle-aware IK optimization. -- Do not change existing `planner_name="roboplan"` behavior beyond any shared helper reuse needed by IK. -- Do not change planning-group semantics to allow overlapping selected joints. - -## Decisions - -### Decision: Use one RoboPlanWorld object for all RoboPlan roles - -`RoboPlanWorld` should implement the existing world role, continue serving as `PlannerSpec` for `planner_name="roboplan"`, and also serve as `KinematicsSpec` for `kinematics.backend="roboplan"`. - -Rationale: RoboPlan IK needs the same scene, contexts, group registry, native order, and collision state that `RoboPlanWorld` already owns. A separate adapter would either call private internals or require an artificial service surface that adds indirection without reducing coupling. - -Alternative considered: create `RoboPlanIK(world, config)` and `RoboPlanPlanner(world)` adapters. Rejected because the user prefers the current single-class pattern, and the existing planner path already established `RoboPlanWorld` as the coupled integration object. - -### Decision: Configure role-specific options after world construction - -`RoboPlanWorld` remains constructed from world/backend options. `create_kinematics(config=RoboPlanKinematicsConfig, world=..., world_backend="roboplan")` should configure the existing world with Oink IK options and return it as `KinematicsSpec`. - -Suggested shape: - -```python -class RoboPlanKinematicsConfig(BaseConfig): - backend: Literal["roboplan"] = "roboplan" - max_iterations: int = 100 - dt: float = 0.05 - position_cost: float = 1.0 - orientation_cost: float = 1.0 - task_gain: float = 0.5 - lm_damping: float = 1e-6 - regularization: float = 1e-8 - velocity_limit: float | None = None - collision_check: bool = True -``` - -Rationale: world options and IK solver options have different lifecycles. Passing all role-specific tuning through the world constructor would make the constructor absorb every future planner/kinematics knob. Configuring the single object after construction preserves one integration object while keeping configs role-specific. - -Alternative considered: avoid a dedicated config class and rely on `kinematics_name="roboplan"` only. Rejected because Oink has real solver knobs that need typed defaults and future CLI/config override support. - -### Decision: Use RoboPlan Oink, not DimOS JacobianIK math - -RoboPlan IK should follow the Oink task-solver pattern: create one `FrameTask` per target, add joint constraints, call `solveIk`, integrate the delta, and iterate until tolerances are met or iteration budget is exhausted. - -Rationale: Oink is RoboPlan's task-based IK surface and supports multiple tasks in one solve call. It also aligns with robokin's RoboPlan wrapper while allowing DimOS to generalize from one target frame to multiple DimOS planning groups. - -Alternative considered: implement a local stacked-Jacobian damped least-squares loop with `get_group_jacobian(...)`. Rejected because it duplicates solver logic already available in RoboPlan and would likely handle task priorities, damping, and constraints less consistently than Oink. - -### Decision: Model multitarget IK as a selected planning-group problem - -`solve_pose_targets(...)` should build a `PlanningGroupSelection` from pose target groups plus auxiliary groups. Existing selection validation rejects overlapping selected joints. RoboPlan IK should resolve that selection to a RoboPlan group or generated composite group, then create one `FrameTask` for each pose target group. - -Rationale: this preserves the current DimOS rule that selected groups cannot overlap. It naturally supports multi-robot targets and same-robot disjoint targets through existing composite group generation, while avoiding ambiguous ownership of shared joints. - -Alternative considered: allow overlapping groups and solve with shared variables. Rejected for v1 because it changes planning-group semantics and would require new conflict-resolution rules for returned selected joints. - -### Decision: Collision validation, not collision-aware IK, in v1 - -RoboPlan IK should optionally check collision freedom of the final selected candidate using existing RoboPlanWorld collision helpers. If the converged candidate is colliding, return an IK failure. It should not add obstacle-avoidance tasks or try to optimize around collisions in this slice. - -Rationale: Oink task IK and path planning serve different purposes. The caller can plan to a valid IK result afterward. Adding collision avoidance inside IK increases scope and tuning complexity before basic RoboPlan-native IK is validated. - -Alternative considered: integrate collision avoidance constraints into Oink immediately. Rejected as broader and less certain than the current need. - -## Risks / Trade-offs - -- `roboplan.optimal_ik` pybind signatures are opaque. → Mitigation: mirror the call pattern validated by robokin and cover the binding surface with faked tests plus at least one import/constructor smoke path when dependencies are available. -- Multi-target IK depends on generated composite group availability. → Mitigation: reuse existing selection-to-group validation and fail with `IKStatus.NO_SOLUTION` when a selection cannot be represented by RoboPlan. -- Oink IK may converge locally but not globally. → Mitigation: document local IK semantics and keep path planning as the follow-up for moving to the IK result. -- Collision-free final states may still be unreachable by a path planner. → Mitigation: treat IK as endpoint generation only; do not weaken planner validation. -- Returning the same object for world, planner, and kinematics increases class responsibility. → Mitigation: keep implementation helpers private and cohesive around RoboPlan scene/group state; revisit splitting only if the class becomes unmanageable. - -## Migration Plan - -1. Add `RoboPlanKinematicsConfig` and include it in the discriminated `ManipulationKinematicsConfig` union and legacy name lookup. -2. Extend supported kinematics and backend-combination validation with `roboplan` requiring `world_backend="roboplan"`. -3. Change `create_kinematics(...)` and `create_planning_specs(...)` so RoboPlan kinematics receives and returns the existing `RoboPlanWorld` object. -4. Add RoboPlanWorld kinematics configuration storage with default values. -5. Implement `solve(...)` as a one-target convenience wrapper over `solve_pose_targets(...)` where compatible with group semantics. -6. Implement `solve_pose_targets(...)` with Oink tasks, selected/composite group resolution, seed/current-state initialization, iterative integration, tolerance checks, optional final collision validation, and selected global joint-state return. -7. Add factory/config tests and RoboPlanWorld unit tests using fakes for `roboplan.optimal_ik`. -8. Run targeted manipulation planning tests and OpenSpec validation. - -Rollback is isolated to the kinematics config/factory additions and `RoboPlanWorld` `KinematicsSpec` methods. Existing RoboPlan planner behavior should remain intact. - -## Open Questions - -- Should Oink `FrameTaskOptions.priority` be exposed in config immediately, or kept at binding/default behavior until a concrete need appears? -- Should velocity limits be configured as one scalar, per-selected-joint values, or initially omitted unless the binding requires them? diff --git a/openspec/changes/roboplan-oink-kinematics/proposal.md b/openspec/changes/roboplan-oink-kinematics/proposal.md deleted file mode 100644 index 32e564cf20..0000000000 --- a/openspec/changes/roboplan-oink-kinematics/proposal.md +++ /dev/null @@ -1,41 +0,0 @@ -## Why - -`RoboPlanWorld` is already the RoboPlan-backed planning world and native planner for DimOS manipulation, but inverse kinematics still routes through separate backends such as Pink or generic Jacobian IK. That forces RoboPlan stacks to cross model/context boundaries for IK even though `RoboPlanWorld` already owns the RoboPlan scene, planning-group metadata, current belief state, joint-limit ordering, collision checks, and group FK/Jacobian queries. - -RoboPlan exposes `roboplan.optimal_ik` bindings locally, including the Oink task solver used by robokin's RoboPlan wrapper. DimOS can use those bindings directly inside `RoboPlanWorld` to provide a RoboPlan-native `KinematicsSpec` without adding a separate adapter object or reviving the deferred robokin backend. - -## What Changes - -- Add `backend="roboplan"` to manipulation kinematics configuration with Oink solver tunables. -- Extend factory validation so `kinematics_name="roboplan"` requires `world_backend="roboplan"`. -- Make `create_kinematics(...)` return the existing `RoboPlanWorld` instance as `KinematicsSpec` for RoboPlan kinematics, matching the current `PlannerSpec` pattern. -- Add `KinematicsSpec.solve(...)` and `solve_pose_targets(...)` behavior to `RoboPlanWorld` using `roboplan.optimal_ik.Oink` with one `FrameTask` per pose target. -- Support multi-target IK for non-overlapping selected planning groups, including multi-robot selections and same-robot disjoint groups represented by generated RoboPlan composite groups. -- Keep IK collision behavior narrow: verify final candidate collision freedom, but do not implement obstacle-aware IK optimization in this slice. -- Keep the deferred robokin backend out of scope. - -## Capabilities - -### New Capabilities - -- `roboplan-oink-kinematics`: RoboPlanWorld provides RoboPlan Oink-backed inverse kinematics through the existing DimOS `KinematicsSpec` surface. - -### Modified Capabilities - -- `roboplan-composite-multi-robot-planning`: RoboPlan composite group metadata is reused by IK target selections; no planner behavior change is required. - -## Impact - -- Affected code areas: - - `dimos/manipulation/planning/kinematics/config.py` - - `dimos/manipulation/planning/factory.py` - - `dimos/manipulation/planning/world/roboplan_world.py` - - `dimos/manipulation/test_planning_factory.py` - - `dimos/manipulation/test_roboplan_world.py` -- Public configuration impact: - - `kinematics_name="roboplan"` and/or `kinematics.backend="roboplan"` become valid only with `world_backend="roboplan"`. -- Dependency impact: - - No new dependency is introduced; the change uses existing `roboplan.optimal_ik` bindings when RoboPlan is installed. -- Behavior impact: - - RoboPlan planning remains wired as `planner_name="roboplan" -> RoboPlanWorld`. - - RoboPlan IK uses the same single `RoboPlanWorld` object rather than a separate adapter. diff --git a/openspec/changes/roboplan-oink-kinematics/tasks.md b/openspec/changes/roboplan-oink-kinematics/tasks.md deleted file mode 100644 index 1d622880ea..0000000000 --- a/openspec/changes/roboplan-oink-kinematics/tasks.md +++ /dev/null @@ -1,36 +0,0 @@ -## 1. Configuration and Factory Wiring - -- [ ] 1.1 Add `RoboPlanKinematicsConfig(backend="roboplan")` with Oink IK tuning fields and include it in `ManipulationKinematicsConfig`. -- [ ] 1.2 Update `kinematics_config_from_name("roboplan")`, `SUPPORTED_KINEMATICS`, and error messages to include RoboPlan kinematics. -- [ ] 1.3 Extend `validate_backend_combination(...)` so `kinematics_name="roboplan"` requires `world_backend="roboplan"`. -- [ ] 1.4 Update `create_kinematics(...)` to accept the existing `world` and `world_backend`, configure `RoboPlanWorld` for IK, and return it as `KinematicsSpec` for RoboPlan kinematics. -- [ ] 1.5 Update `create_planning_specs(...)` to pass the world into `create_kinematics(...)` while preserving existing non-RoboPlan kinematics behavior. - -## 2. RoboPlanWorld Kinematics Implementation - -- [ ] 2.1 Add role-specific IK config storage and a `configure_kinematics(...)` method on `RoboPlanWorld`. -- [ ] 2.2 Import or lazy-load `roboplan.optimal_ik` with clear actionable errors when the Oink binding is unavailable. -- [ ] 2.3 Implement `solve(...)` as a single-target convenience path or a clear unsupported path when no planning group can be identified. -- [ ] 2.4 Implement `solve_pose_targets(...)` using `PlanningGroupSelection` for pose and auxiliary groups. -- [ ] 2.5 Resolve selected groups to the RoboPlan native or generated composite group required by `Oink(scene, group_name)`. -- [ ] 2.6 Create one `FrameTask` per pose target using the target group's base/tip frame metadata and requested `PoseStamped` target. -- [ ] 2.7 Add `PositionLimit` and optional `VelocityLimit` constraints from the kinematics config. -- [ ] 2.8 Seed from the provided `JointState` when complete, otherwise fall back to the current RoboPlanWorld belief state. -- [ ] 2.9 Iterate `solveIk`, integrate the candidate, clamp/validate limits, check pose tolerances, and return selected global joints in selection order. -- [ ] 2.10 Optionally validate final collision freedom and fail clearly if the converged IK state is colliding. - -## 3. Tests - -- [ ] 3.1 Add config tests for parsing/defaults of `RoboPlanKinematicsConfig`. -- [ ] 3.2 Add factory tests that RoboPlan kinematics requires RoboPlan world backend and returns the same world object. -- [ ] 3.3 Add RoboPlanWorld unit tests for Oink task creation, multi-target task list construction, seed/current-state fallback, selected global joint return order, and auxiliary group retention. -- [ ] 3.4 Add failure tests for empty targets, missing pose target frame, overlapping selected groups, unsupported composite selection, missing `roboplan.optimal_ik`, non-convergence, and colliding final candidate. -- [ ] 3.5 Keep tests simulator-free with faked RoboPlan Oink bindings where possible. - -## 4. Validation and Documentation - -- [ ] 4.1 Run `uv run pytest dimos/manipulation/test_planning_factory.py -v`. -- [ ] 4.2 Run `uv run pytest dimos/manipulation/test_roboplan_world.py -v`. -- [ ] 4.3 Run targeted kinematics/config tests added for this change. -- [ ] 4.4 Run OpenSpec validation for `roboplan-oink-kinematics` and fix artifact issues. -- [ ] 4.5 Document any remaining Oink binding limitations discovered during implementation. diff --git a/openspec/changes/roboplan-oink-kinematics/specs/roboplan-oink-kinematics/spec.md b/openspec/specs/roboplan-oink-kinematics/spec.md similarity index 95% rename from openspec/changes/roboplan-oink-kinematics/specs/roboplan-oink-kinematics/spec.md rename to openspec/specs/roboplan-oink-kinematics/spec.md index 74503ec656..8a9c6d6821 100644 --- a/openspec/changes/roboplan-oink-kinematics/specs/roboplan-oink-kinematics/spec.md +++ b/openspec/specs/roboplan-oink-kinematics/spec.md @@ -1,4 +1,8 @@ -## ADDED Requirements +## Purpose + +RoboPlan Oink kinematics lets RoboPlan-backed manipulation stacks use the existing RoboPlanWorld scene, planning-group metadata, joint ordering, and collision checks through the DimOS KinematicsSpec surface. + +## Requirements ### Requirement: RoboPlan kinematics configuration The manipulation kinematics configuration SHALL support a `roboplan` backend with typed Oink solver tuning fields. From d65917dedd3164a3f2ee71f5baeb42c1205f97e2 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 21:22:48 -0700 Subject: [PATCH 089/110] spec: libero-pro --- CONTEXT.md | 4 + .../libero-pro-runtime-sidecar/.openspec.yaml | 2 + .../libero-pro-runtime-sidecar/design.md | 91 +++++++++++++++++++ .../libero-pro-runtime-sidecar/proposal.md | 31 +++++++ .../benchmark-prelaunch-orchestration/spec.md | 36 ++++++++ .../specs/libero-pro-runtime-sidecar/spec.md | 82 +++++++++++++++++ .../specs/scripted-runtime-demos/spec.md | 35 +++++++ .../libero-pro-runtime-sidecar/tasks.md | 53 +++++++++++ 8 files changed, 334 insertions(+) create mode 100644 openspec/changes/libero-pro-runtime-sidecar/.openspec.yaml create mode 100644 openspec/changes/libero-pro-runtime-sidecar/design.md create mode 100644 openspec/changes/libero-pro-runtime-sidecar/proposal.md create mode 100644 openspec/changes/libero-pro-runtime-sidecar/specs/benchmark-prelaunch-orchestration/spec.md create mode 100644 openspec/changes/libero-pro-runtime-sidecar/specs/libero-pro-runtime-sidecar/spec.md create mode 100644 openspec/changes/libero-pro-runtime-sidecar/specs/scripted-runtime-demos/spec.md create mode 100644 openspec/changes/libero-pro-runtime-sidecar/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md index 75ff5b250b..6f665193b7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -148,6 +148,10 @@ _Avoid_: benchmark intent, user-authored task config The phase that starts and coordinates the simulator sidecar environment and the DimOS blueprint environment before a benchmark episode begins. _Avoid_: config parsing, blueprint launch, single-process startup +**Runtime asset bootstrap**: +A deliberate preparation phase that retrieves, stages, or validates external benchmark assets before a runtime sidecar starts an episode. +_Avoid_: implicit sidecar download, startup mutation, hidden dataset setup + **Remote runtime boundary**: The network-facing protocol boundary between a DimOS simulator client and a benchmark backend process that may run in another environment or on another machine. _Avoid_: shared memory boundary, hardware adapter boundary, in-process simulator object diff --git a/openspec/changes/libero-pro-runtime-sidecar/.openspec.yaml b/openspec/changes/libero-pro-runtime-sidecar/.openspec.yaml new file mode 100644 index 0000000000..f9be753a1e --- /dev/null +++ b/openspec/changes/libero-pro-runtime-sidecar/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-27 diff --git a/openspec/changes/libero-pro-runtime-sidecar/design.md b/openspec/changes/libero-pro-runtime-sidecar/design.md new file mode 100644 index 0000000000..79056ada5d --- /dev/null +++ b/openspec/changes/libero-pro-runtime-sidecar/design.md @@ -0,0 +1,91 @@ +## Context + +DimOS already has a runtime-sidecar architecture where simulator-specific dependencies live outside the main DimOS process. The shared runtime protocol package defines backend-neutral models for runtime description, reset, step, observations, motor action/state frames, scores, artifacts, and errors. The Robosuite sidecar and demos prove the pattern for a narrow Panda Lift task, using a single-threaded HTTP server, runtime-derived motor metadata, `.npy` camera payload references, a local SHM motor bridge, and a `ControlCoordinator` loop. + +LIBERO-PRO should reuse that boundary but cannot be treated as another in-process DimOS module. It relies on a LIBERO/Robosuite/Torch stack that is older and more constrained than the main DimOS environment, and its task identity comes from LIBERO benchmark suites, BDDL files, init states, language metadata, and prepared assets. + +## Goals / Non-Goals + +**Goals:** + +- Provide a dedicated LIBERO-PRO runtime sidecar package that imports no main DimOS package and imports heavy LIBERO dependencies lazily. +- Support registered LIBERO-PRO suites in v1 with typed backend options for suite/task/init-state selection and runtime settings. +- Deliver the full local DimOS control demo path in v1: sidecar, runtime protocol endpoints, SHM motor bridge, `HardwareComponent(adapter_type="benchmark_runtime")`, `ControlCoordinator`, camera payloads, score output, artifacts, and teardown. +- Preserve the shared runtime protocol schema for v1 and keep it backend-neutral. +- Make asset preparation explicit and opt-in, while validating prepared assets before episode startup. +- Fail fast when the selected LIBERO-PRO task/controller cannot expose the canonical Panda joint-position plus gripper whole-body motor surface. + +**Non-Goals:** + +- Dynamic LIBERO-PRO perturbation generation through `perturbation.create_env(...)`. +- Silent action adaptation from DimOS joint targets into OSC pose actions. +- Automatic asset download or filesystem mutation during sidecar startup or `/health`. +- Requiring real LIBERO-PRO dependencies/assets in normal CI. +- Adding a new `dimos benchmark` CLI command. +- Extending shared runtime protocol models with LIBERO-PRO-specific task objects in v1. + +## Decisions + +### Dedicated sidecar package + +Create `packages/dimos-libero-pro-sidecar/` with its own `pyproject.toml`, import-safe package, and console scripts. The package depends on `dimos-runtime-protocol` by default and declares LIBERO-PRO/runtime extras where feasible. + +Alternatives considered: + +- Extend `dimos-robosuite-sidecar`: rejected because LIBERO-PRO adds benchmark-suite/task/asset machinery and an older dependency stack that should not be coupled to the current Robosuite sidecar profile. +- Host LIBERO-PRO as a DimOS Module or venv module worker: rejected for v1 because the sidecar boundary already matches simulator ownership and avoids main DimOS module import/build lifecycle coupling. + +### Registered-suite v1 scope + +Use `libero.benchmark.get_benchmark(benchmark_name)(task_order_index)` and `benchmark.get_task(task_index)` to resolve BDDL path, language, init states, and task metadata. Reset creates `OffScreenRenderEnv`, calls `reset()`, and applies `set_init_state(init_states[init_state_index])`. + +Dynamic perturbation generation remains a follow-up because it materializes temporary assets and introduces additional validation/failure modes before the base runtime boundary is proven. + +### Typed backend options outside shared protocol models + +Add a `backend_options` field to local benchmark episode config and validate LIBERO-PRO values through a sidecar/config-local `LiberoProBackendOptions` model. Common top-level episode fields remain only the values DimOS reasons over generically: backend, episode id, runtime endpoint, robot id, degree of freedom count, control rate, tick count, and artifact directory. + +The runtime protocol remains unchanged. `EpisodeResetRequest.options`, `RuntimeDescription.metadata`, `StepResponse.info`, and `ScoreOutput.metrics` are expressive enough for v1 metadata. Shared protocol extensions are deferred until multiple backends need typed benchmark-task semantics or sidecar-owned artifact endpoints. + +### Canonical motor surface: Panda joint-position plus gripper + +The LIBERO-PRO full-control demo requires an ordered whole-body motor surface compatible with Panda joint-position commands plus gripper. The sidecar validates the selected controller/action space against the expected motor order and action dimension. If LIBERO-PRO exposes only OSC pose control or an incompatible action surface for the chosen task, setup fails with a clear protocol error. + +This preserves DimOS's `Whole-body motor surface` contract and avoids a hidden policy layer that would translate joint commands into task-space pose commands. + +### Explicit runtime asset bootstrap + +Add an optional asset preparation command or demo flag, such as `--prepare-assets`, that uses Hugging Face APIs where enabled and supports manual/prepared paths. Startup and `/health` only validate assets and report clear failures; they do not download or mutate local asset layout. + +The sidecar validates the presence of required BDDL and init-state assets for the selected registered suite/task before starting the episode. + +### Sidecar-owned scoring + +The LIBERO-PRO sidecar owns success extraction because it has direct access to the backend env/task APIs. `/score` exposes normalized success, reward/score, step count, and task metadata such as benchmark name, task name, language, and init-state index. The DimOS demo records this score instead of inferring success from rewards, observations, or images. + +### Verification split + +Always-on tests cover import boundaries, typed backend option validation, stubbed sidecar endpoint behavior, motor-surface validation, score shape, and config resolution. Real LIBERO-PRO dependency/data tests are optional/manual because they require a prepared external environment and assets. + +## Risks / Trade-offs + +- Controller/action mismatch → Fail before episode start unless the chosen LIBERO-PRO env exposes Panda joint-position plus gripper with the expected action dimension and motor order. +- Old dependency stack fragility → Keep LIBERO-PRO dependencies isolated in a dedicated sidecar environment and test import boundaries so main DimOS remains unaffected. +- Asset layout drift or missing data → Provide explicit asset validation and optional bootstrap, and make setup failures visible instead of hiding them behind startup downloads. +- Untyped metadata/options can become brittle → Use local typed `LiberoProBackendOptions` now; revisit shared protocol extensions only when typed benchmark-task semantics become cross-backend. +- Optional real integration coverage may miss environment-specific failures in CI → Keep stub tests strict and provide a documented manual marker/demo for real prepared environments. +- Full-control v1 is larger than a smoke-only milestone → Keep dynamic perturbations and protocol extensions out of scope so the added work stays focused on the selected registered-suite control path. + +## Migration Plan + +1. Add backend-options support in local benchmark episode config without removing existing top-level Robosuite fields. +2. Add the LIBERO-PRO sidecar package and stub-friendly HTTP endpoint implementation. +3. Add LIBERO-PRO config/demo artifacts and tests while preserving existing fake and Robosuite demos. +4. Keep optional real LIBERO-PRO integration tests/manual demos behind explicit markers or commands. + +Rollback is straightforward because the change is additive: remove the new package, config/demo entries, tests, and `backend_options` usage while leaving existing runtime protocol and Robosuite paths intact. + +## Open Questions + +- The exact LIBERO-PRO controller config name and action-vector layout must be verified in a real prepared LIBERO-PRO environment before marking the full-control demo as manually passing. +- The final asset bootstrap CLI shape can be a sidecar console script, demo flag, or both, but it must remain explicit opt-in. diff --git a/openspec/changes/libero-pro-runtime-sidecar/proposal.md b/openspec/changes/libero-pro-runtime-sidecar/proposal.md new file mode 100644 index 0000000000..5e3f95d93d --- /dev/null +++ b/openspec/changes/libero-pro-runtime-sidecar/proposal.md @@ -0,0 +1,31 @@ +## Why + +DimOS already has a backend-neutral runtime sidecar pattern and a Robosuite Panda demo, but LIBERO-PRO tasks require a separate old LIBERO/Robosuite/Torch stack, registered benchmark-suite task selection, prepared BDDL/init assets, and sidecar-owned success extraction. Supporting LIBERO-PRO through the same runtime boundary lets DimOS validate whole-body motor control, observations, scoring, and artifacts without importing LIBERO-PRO into the main DimOS environment. + +## What Changes + +- Add a dedicated LIBERO-PRO runtime sidecar package that depends on the runtime protocol package and isolates LIBERO-PRO dependencies from the main DimOS package. +- Support registered LIBERO-PRO suites in v1 using backend options for `benchmark_name`, `task_order_index`, `task_index`, `init_state_index`, controller, camera, horizon, asset paths, and validation behavior. +- Require the v1 full-control path to expose a Panda joint-position plus gripper whole-body motor surface; fail fast when a selected LIBERO-PRO environment/controller cannot provide that action contract. +- Add explicit opt-in runtime asset bootstrap support for preparing or validating LIBERO-PRO BDDL/init assets, while keeping startup and `/health` validation non-mutating. +- Add a script-hosted LIBERO-PRO ControlCoordinator + SHM demo equivalent to the Robosuite demo: sidecar startup, runtime description, reset, stepping, camera payload fetching, motor bridge loop, score collection, artifact writing, and teardown. +- Keep the shared runtime protocol schema backend-neutral and unchanged for v1; typed LIBERO-PRO validation lives in local config/sidecar/demo code. +- Split verification into always-on contract/import/stub tests and optional manual real LIBERO-PRO integration tests requiring prepared dependencies and assets. + +## Capabilities + +### New Capabilities +- `libero-pro-runtime-sidecar`: Dedicated LIBERO-PRO runtime sidecar behavior, task selection, asset validation/bootstrap, motor-surface validation, observation export, and sidecar-owned scoring. + +### Modified Capabilities +- `benchmark-prelaunch-orchestration`: Add backend-specific options to benchmark episode config and require resolved plans to validate LIBERO-PRO sidecar metadata without adding LIBERO-specific top-level config fields. +- `scripted-runtime-demos`: Add a full LIBERO-PRO script-hosted runtime demo using the same ControlCoordinator, SHM motor bridge, observation payload, score, artifact, and teardown expectations as the Robosuite demo. + +## Impact + +- Adds `packages/dimos-libero-pro-sidecar/` with its own package metadata, console script, sidecar server, typed backend options, and optional asset bootstrap entrypoint. +- Updates benchmark runtime config parsing to support a backend-neutral `backend_options` field while preserving existing Robosuite/fake configs. +- Adds LIBERO-PRO benchmark config and script under benchmark runtime/demo locations. +- Adds always-on tests for import boundaries, backend option validation, sidecar HTTP contract behavior with stubs, action-surface failure, score shape, and config resolution. +- Adds optional/manual integration coverage for real LIBERO-PRO dependencies/assets and the full ControlCoordinator + SHM demo. +- Does not change `packages/dimos-runtime-protocol` wire models in v1. diff --git a/openspec/changes/libero-pro-runtime-sidecar/specs/benchmark-prelaunch-orchestration/spec.md b/openspec/changes/libero-pro-runtime-sidecar/specs/benchmark-prelaunch-orchestration/spec.md new file mode 100644 index 0000000000..ef5e59ed31 --- /dev/null +++ b/openspec/changes/libero-pro-runtime-sidecar/specs/benchmark-prelaunch-orchestration/spec.md @@ -0,0 +1,36 @@ +## MODIFIED Requirements + +### Requirement: Benchmark episode config +The system SHALL support a benchmark episode config that declares benchmark intent, including backend selection, task identity, robot profile, control constraints, observation needs, evaluator expectations, artifact destination, and backend-specific options before a DimOS blueprint is launched. + +#### Scenario: Robosuite task is declared as benchmark intent +- **WHEN** an episode config names backend `robosuite`, env name `Lift`, robot `Panda`, controller profile, control frequency, horizon, and seed +- **THEN** the config is treated as portable benchmark intent rather than as a precomputed DimOS hardware component + +#### Scenario: LIBERO-PRO task is declared with backend options +- **WHEN** an episode config names backend `libero-pro` with common runtime fields and backend options for benchmark name, task order index, task index, init-state index, controller, cameras, horizon, and asset paths +- **THEN** the config is treated as portable benchmark intent without adding LIBERO-PRO-specific fields to the common top-level config surface + +## ADDED Requirements + +### Requirement: Backend-specific option validation +The system SHALL validate backend-specific episode options before deriving a resolved runtime plan, using typed validation for LIBERO-PRO options without requiring shared runtime protocol models to expose LIBERO-PRO task types. + +#### Scenario: Valid LIBERO-PRO options are accepted +- **WHEN** a `libero-pro` episode config provides required registered-suite task selection and runtime options +- **THEN** validation produces typed backend options that can be passed to sidecar launch, reset, and demo orchestration + +#### Scenario: Missing LIBERO-PRO options fail before launch +- **WHEN** a `libero-pro` episode config omits required suite, task, init-state, or asset validation options +- **THEN** prelaunch fails before starting the DimOS blueprint and reports the invalid backend option + +### Requirement: LIBERO-PRO resolved runtime plan validation +The system SHALL derive LIBERO-PRO resolved runtime plans from live sidecar metadata and SHALL reject mismatches between episode config and the sidecar-described motor surface, backend, protocol version, or robot identity before starting the DimOS blueprint. + +#### Scenario: LIBERO-PRO hardware component is derived from sidecar metadata +- **WHEN** the LIBERO-PRO sidecar reports an ordered Panda whole-body motor surface compatible with the requested robot id and degree of freedom count +- **THEN** the resolved runtime plan includes a matching benchmark runtime hardware component for the ControlCoordinator-facing adapter + +#### Scenario: LIBERO-PRO motor surface mismatch fails +- **WHEN** the LIBERO-PRO sidecar reports a backend, robot id, motor count, or supported command mode that is incompatible with the episode config +- **THEN** prelaunch fails before starting the DimOS blueprint and records the mismatch diff --git a/openspec/changes/libero-pro-runtime-sidecar/specs/libero-pro-runtime-sidecar/spec.md b/openspec/changes/libero-pro-runtime-sidecar/specs/libero-pro-runtime-sidecar/spec.md new file mode 100644 index 0000000000..24810cc94c --- /dev/null +++ b/openspec/changes/libero-pro-runtime-sidecar/specs/libero-pro-runtime-sidecar/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### Requirement: LIBERO-PRO sidecar package +The system SHALL provide a first-class LIBERO-PRO runtime sidecar package in the monorepo that depends on the runtime protocol package and isolates LIBERO-PRO-specific dependencies from the main DimOS package. + +#### Scenario: LIBERO-PRO sidecar imports without DimOS +- **WHEN** a developer imports the LIBERO-PRO sidecar package module in a normal DimOS development environment without LIBERO-PRO installed +- **THEN** the import succeeds without importing `dimos`, `libero`, `robosuite`, or `torch` + +#### Scenario: LIBERO-PRO sidecar installs in isolated environment +- **WHEN** a developer installs the LIBERO-PRO sidecar package in a LIBERO-PRO-compatible environment +- **THEN** the sidecar can start and import runtime protocol models without installing the main DimOS package + +### Requirement: Registered LIBERO-PRO task selection +The LIBERO-PRO sidecar SHALL support registered LIBERO-PRO benchmark suites in v1 using backend options for benchmark name, task order index, task index, init-state index, controller, cameras, horizon, and asset roots. + +#### Scenario: Registered task is described +- **WHEN** the sidecar is configured with a registered LIBERO-PRO benchmark name, task order index, task index, and init-state index +- **THEN** the runtime description includes task metadata such as benchmark name, task name, language, BDDL path, init-state index, controller, horizon, and camera configuration + +#### Scenario: Dynamic perturbation request is rejected +- **WHEN** v1 configuration requests dynamic perturbation generation instead of a registered prepared suite task +- **THEN** the sidecar rejects the setup with a clear error before starting an episode + +### Requirement: LIBERO-PRO asset validation and bootstrap +The system SHALL support explicit opt-in LIBERO-PRO runtime asset bootstrap while requiring sidecar startup and health checks to validate prepared assets without downloading or mutating local asset layout. + +#### Scenario: Prepared assets validate successfully +- **WHEN** required BDDL and init-state assets exist for the selected registered suite task +- **THEN** the sidecar health or setup validation reports the assets as usable without modifying them + +#### Scenario: Missing assets fail clearly +- **WHEN** required BDDL or init-state assets are missing for the selected registered suite task +- **THEN** the sidecar reports a clear validation failure that identifies the missing asset category before episode reset + +#### Scenario: Asset bootstrap is explicit +- **WHEN** a developer requests asset preparation through an explicit bootstrap command or demo flag +- **THEN** the system may retrieve and stage supported external assets and then validates the resulting layout before sidecar use + +### Requirement: LIBERO-PRO motor surface validation +The LIBERO-PRO sidecar SHALL expose the full-control v1 path only when the selected task and controller provide a Panda joint-position plus gripper whole-body motor surface compatible with DimOS motor action frames. + +#### Scenario: Compatible motor surface is described +- **WHEN** the selected LIBERO-PRO environment exposes the expected Panda joint-position plus gripper action surface +- **THEN** the runtime description reports a stable ordered motor surface with supported position command mode and the expected motor count + +#### Scenario: Incompatible controller fails fast +- **WHEN** the selected LIBERO-PRO environment exposes only OSC pose control or an action dimension that cannot be mapped to Panda joint-position plus gripper commands +- **THEN** the sidecar rejects the episode setup with a clear protocol error before accepting step requests + +### Requirement: LIBERO-PRO step ownership and observation export +The LIBERO-PRO sidecar SHALL own backend-native environment reset and step calls and SHALL translate runtime protocol action frames into LIBERO-PRO actions while exporting motor state, reward, done, success, and observation metadata. + +#### Scenario: LIBERO-PRO reset applies init state +- **WHEN** DimOS requests episode reset for a configured LIBERO-PRO registered task +- **THEN** the sidecar resets the environment, applies the selected init state, and returns initial motor state and task metadata + +#### Scenario: Motor step advances LIBERO-PRO +- **WHEN** DimOS sends a motor position action frame for the described Panda motor surface +- **THEN** the sidecar maps the action to the LIBERO-PRO environment step and returns motor state, reward, done, success if available, and observation frames + +#### Scenario: Camera payload can be fetched +- **WHEN** a LIBERO-PRO step response includes a camera observation with a payload reference +- **THEN** the DimOS runtime client can fetch the referenced `.npy` payload for stream publication and artifacts + +### Requirement: Sidecar-owned LIBERO-PRO score +The LIBERO-PRO sidecar SHALL provide normalized episode score output that includes backend-owned success extraction, reward or score, step count, and task metadata. + +#### Scenario: Score is collected after episode +- **WHEN** the LIBERO-PRO demo completes, times out, or reaches done +- **THEN** the runner can request score output from the sidecar and write success, reward or score, steps, benchmark name, task name, language, and init-state index with episode artifacts + +### Requirement: LIBERO-PRO verification split +The system SHALL verify LIBERO-PRO sidecar behavior with always-on contract tests that do not require real LIBERO-PRO dependencies or data, and SHALL keep real LIBERO-PRO execution behind optional/manual integration coverage. + +#### Scenario: Normal CI runs without LIBERO-PRO data +- **WHEN** normal test suites run in the main DimOS development environment +- **THEN** they verify import boundaries, backend option validation, stubbed sidecar endpoints, action-surface failures, and score shape without requiring LIBERO-PRO assets or dependencies + +#### Scenario: Manual integration exercises real LIBERO-PRO +- **WHEN** a developer runs the optional real LIBERO-PRO integration with prepared dependencies and assets +- **THEN** it launches the real sidecar, runs the full ControlCoordinator and SHM demo path for one registered task, fetches camera payloads, and writes score and artifacts diff --git a/openspec/changes/libero-pro-runtime-sidecar/specs/scripted-runtime-demos/spec.md b/openspec/changes/libero-pro-runtime-sidecar/specs/scripted-runtime-demos/spec.md new file mode 100644 index 0000000000..e93e74cf37 --- /dev/null +++ b/openspec/changes/libero-pro-runtime-sidecar/specs/scripted-runtime-demos/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: LIBERO-PRO full-control runtime demo +The system SHALL include a script-based LIBERO-PRO runtime demo that validates the real LIBERO-PRO sidecar, runtime description, registered task reset, local SHM motor bridge, ControlCoordinator integration, camera payload export, score collection, artifact output, and teardown. + +#### Scenario: LIBERO-PRO demo starts sidecar and DimOS control path +- **WHEN** a developer runs the LIBERO-PRO demo script with a compatible sidecar environment and prepared registered-suite assets +- **THEN** the script starts the sidecar, obtains runtime metadata, resolves the runtime plan, starts the DimOS control path, runs the configured tick loop, collects artifacts, and tears down both runtimes + +#### Scenario: LIBERO-PRO demo exercises motor command and state flow +- **WHEN** the LIBERO-PRO demo sends scripted Panda motor position targets through the ControlCoordinator-facing path +- **THEN** commands flow through the local SHM motor bridge to the runtime protocol client, and sidecar-returned motor states flow back into the DimOS side and are recorded in the motor trace + +#### Scenario: LIBERO-PRO camera payload is exported +- **WHEN** the LIBERO-PRO demo enables a configured camera observation stream +- **THEN** the demo fetches referenced `.npy` camera payloads and publishes or records at least one camera observation through the runtime observation path + +#### Scenario: LIBERO-PRO score is recorded +- **WHEN** the LIBERO-PRO demo completes, times out, or reaches done +- **THEN** the demo requests sidecar-owned score output and writes success, reward or score, step count, task metadata, protocol trace summary, motor trace, and logs to the artifact directory + +#### Scenario: LIBERO-PRO demo does not require agent task success +- **WHEN** the scripted LIBERO-PRO demo does not solve the selected task successfully +- **THEN** the demo can still pass if protocol, motor flow, observation flow, score collection, and teardown satisfy the demo acceptance checks + +### Requirement: LIBERO-PRO asset preparation remains explicit +The LIBERO-PRO scripted demo SHALL NOT download or mutate benchmark assets unless the developer passes an explicit asset preparation flag or runs an explicit preparation command. + +#### Scenario: Demo validates assets by default +- **WHEN** a developer runs the LIBERO-PRO demo without an asset preparation option +- **THEN** the demo validates prepared asset paths and fails clearly if required assets are missing + +#### Scenario: Demo prepares assets only when requested +- **WHEN** a developer runs the LIBERO-PRO demo with an explicit asset preparation option +- **THEN** the demo may run the runtime asset bootstrap before launching the sidecar and still validates the prepared layout before episode reset diff --git a/openspec/changes/libero-pro-runtime-sidecar/tasks.md b/openspec/changes/libero-pro-runtime-sidecar/tasks.md new file mode 100644 index 0000000000..f7ab281cca --- /dev/null +++ b/openspec/changes/libero-pro-runtime-sidecar/tasks.md @@ -0,0 +1,53 @@ +## 1. Runtime Config and Planning + +- [ ] 1.1 Add `backend_options` support to `BenchmarkEpisodeConfig` without breaking existing fake and Robosuite configs. +- [ ] 1.2 Add typed LIBERO-PRO backend option validation for benchmark name, task order index, task index, init-state index, controller, cameras, horizon, and asset roots. +- [ ] 1.3 Update runtime plan resolution or demo-side validation so `libero-pro` plans fail before blueprint startup when backend, robot id, motor count, protocol version, or command modes mismatch sidecar metadata. +- [ ] 1.4 Add a LIBERO-PRO benchmark config fixture for one registered suite/task using common top-level fields plus `backend_options`. + +## 2. LIBERO-PRO Sidecar Package + +- [ ] 2.1 Create `packages/dimos-libero-pro-sidecar/` with package metadata, import-safe module structure, and console script entrypoints. +- [ ] 2.2 Implement lazy LIBERO-PRO imports so importing the sidecar package does not import `dimos`, `libero`, `robosuite`, or `torch`. +- [ ] 2.3 Implement sidecar configuration/state models for registered-suite task selection and runtime settings. +- [ ] 2.4 Implement `/health`, `/describe`, `/reset`, `/step`, `/score`, and `/payloads/{id}` endpoints using the existing runtime protocol models. +- [ ] 2.5 Keep the LIBERO-PRO HTTP server single-threaded for MuJoCo/Robosuite render-context safety. + +## 3. LIBERO-PRO Task and Motor Runtime + +- [ ] 3.1 Resolve registered LIBERO-PRO tasks via `get_benchmark(benchmark_name)(task_order_index)` and `benchmark.get_task(task_index)`. +- [ ] 3.2 Create and reset `OffScreenRenderEnv`, apply `set_init_state(init_states[init_state_index])`, and return initial protocol state/metadata. +- [ ] 3.3 Validate the selected controller/action space exposes Panda joint-position plus gripper with the expected motor order and action dimension. +- [ ] 3.4 Translate `MotorActionFrame` position commands into LIBERO-PRO environment actions and translate returned simulator state into `MotorStateFrame`. +- [ ] 3.5 Export configured camera observations as runtime observation frames with fetchable `.npy` payload references. +- [ ] 3.6 Normalize sidecar-owned score output with success, reward or score, steps, benchmark name, task name, language, and init-state index. + +## 4. Asset Bootstrap and Validation + +- [ ] 4.1 Add explicit asset validation that detects missing BDDL and init-state assets for the selected registered suite/task before episode reset. +- [ ] 4.2 Add an optional asset bootstrap command or demo flag that uses Hugging Face APIs where enabled and stages supported assets into the expected LIBERO-PRO layout. +- [ ] 4.3 Ensure sidecar startup and `/health` validate assets without downloading or mutating local files unless explicit bootstrap was requested. +- [ ] 4.4 Document manual/prepared asset path usage and bootstrap behavior in the sidecar or runtime-sidecar docs. + +## 5. Full-Control Demo + +- [ ] 5.1 Add `scripts/benchmarks/demo_libero_pro_runtime.py` modeled on the Robosuite demo flow. +- [ ] 5.2 Launch the LIBERO-PRO sidecar subprocess, wait for health, fetch runtime description, and derive the resolved runtime plan. +- [ ] 5.3 Create the SHM motor owner and benchmark runtime hardware component for `HardwareComponent(adapter_type="benchmark_runtime")`. +- [ ] 5.4 Run a scripted `ControlCoordinator` servo loop that sends Panda motor position targets, posts `/step`, writes returned motor state to SHM, and records motor/protocol traces. +- [ ] 5.5 Fetch camera payloads and publish or record at least one camera observation through the runtime observation path. +- [ ] 5.6 Request `/score`, write score/artifact outputs, and guarantee sidecar, coordinator, and SHM cleanup on success and failure. + +## 6. Always-On Tests + +- [ ] 6.1 Add import-boundary tests ensuring the LIBERO-PRO sidecar package imports without loading `dimos`, `libero`, `robosuite`, or `torch`. +- [ ] 6.2 Add backend-options validation tests for valid registered-suite config and missing/invalid LIBERO-PRO option failures. +- [ ] 6.3 Add stubbed sidecar endpoint tests for `/describe`, `/reset`, `/step`, `/score`, and camera payload behavior without real LIBERO-PRO dependencies. +- [ ] 6.4 Add action-surface validation tests covering compatible Panda joint-position plus gripper metadata and incompatible OSC/action-dimension failures. +- [ ] 6.5 Add config/plan tests confirming existing fake and Robosuite configs still resolve after introducing `backend_options`. + +## 7. Optional Real Integration + +- [ ] 7.1 Add optional/manual test marker coverage for launching a real LIBERO-PRO sidecar with prepared assets. +- [ ] 7.2 Verify the full ControlCoordinator + SHM demo path against one registered LIBERO-PRO suite/task in a prepared environment. +- [ ] 7.3 Record manual verification instructions, required environment assumptions, asset preparation steps, and expected artifacts. From 813386abffaa0eda0cabccad34afba09e5b733e6 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 21:39:29 -0700 Subject: [PATCH 090/110] feat: add venv deployable modules --- .gitignore | 4 +- dimos/core/coordination/blueprints.py | 32 ++- dimos/core/coordination/module_coordinator.py | 223 ++++++++++++++++-- dimos/core/coordination/python_worker.py | 78 +++--- dimos/core/coordination/test_blueprints.py | 35 ++- .../coordination/test_module_coordinator.py | 221 +++++++++++++++++ dimos/core/coordination/test_worker.py | 110 +++++++++ .../coordination/venv_worker_entrypoint.py | 34 +++ dimos/core/coordination/worker_launcher.py | 220 +++++++++++++++++ .../coordination/worker_manager_python.py | 25 +- dimos/core/native_module.py | 68 +++++- dimos/core/runtime_environment.py | 119 ++++++++++ dimos/core/test_native_module.py | 187 +++++++++++++++ dimos/core/test_runtime_environment.py | 88 +++++++ dimos/core/test_venv_module_demo.py | 74 ++++++ docs/usage/README.md | 1 + docs/usage/native_modules.md | 34 ++- docs/usage/runtime_environments.md | 120 ++++++++++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../runtime-environment-registry/spec.md | 0 .../specs/venv-module-packaging/spec.md | 14 +- .../specs/venv-module-placement/spec.md | 0 .../tasks.md | 52 ++++ .../changes/venv-deployable-modules/tasks.md | 52 ---- .../runtime-environment-registry/spec.md | 49 ++++ openspec/specs/venv-module-packaging/spec.md | 49 ++++ openspec/specs/venv-module-placement/spec.md | 64 +++++ .../dimos-demo-worker-module/pyproject.toml | 10 + .../src/dimos_demo_worker_module/__init__.py | 13 + .../src/dimos_demo_worker_module/blueprint.py | 54 +++++ .../runtime_dependency.py | 16 ++ scripts/demo_venv_worker_module.py | 73 ++++++ 34 files changed, 1978 insertions(+), 141 deletions(-) create mode 100644 dimos/core/coordination/venv_worker_entrypoint.py create mode 100644 dimos/core/coordination/worker_launcher.py create mode 100644 dimos/core/runtime_environment.py create mode 100644 dimos/core/test_runtime_environment.py create mode 100644 dimos/core/test_venv_module_demo.py create mode 100644 docs/usage/runtime_environments.md rename openspec/changes/{venv-deployable-modules => archive/2026-06-26-venv-deployable-modules}/.openspec.yaml (100%) rename openspec/changes/{venv-deployable-modules => archive/2026-06-26-venv-deployable-modules}/design.md (100%) rename openspec/changes/{venv-deployable-modules => archive/2026-06-26-venv-deployable-modules}/proposal.md (100%) rename openspec/changes/{venv-deployable-modules => archive/2026-06-26-venv-deployable-modules}/specs/runtime-environment-registry/spec.md (100%) rename openspec/changes/{venv-deployable-modules => archive/2026-06-26-venv-deployable-modules}/specs/venv-module-packaging/spec.md (80%) rename openspec/changes/{venv-deployable-modules => archive/2026-06-26-venv-deployable-modules}/specs/venv-module-placement/spec.md (100%) create mode 100644 openspec/changes/archive/2026-06-26-venv-deployable-modules/tasks.md delete mode 100644 openspec/changes/venv-deployable-modules/tasks.md create mode 100644 openspec/specs/runtime-environment-registry/spec.md create mode 100644 openspec/specs/venv-module-packaging/spec.md create mode 100644 openspec/specs/venv-module-placement/spec.md create mode 100644 packages/dimos-demo-worker-module/pyproject.toml create mode 100644 packages/dimos-demo-worker-module/src/dimos_demo_worker_module/__init__.py create mode 100644 packages/dimos-demo-worker-module/src/dimos_demo_worker_module/blueprint.py create mode 100644 packages/dimos-demo-worker-module/src/dimos_demo_worker_module/runtime_dependency.py create mode 100644 scripts/demo_venv_worker_module.py diff --git a/.gitignore b/.gitignore index 484a73e809..69a0fdc85c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,11 @@ __pycache__/ # Ignore virtual environment directories *venv*/ .venv*/ -# Keep OpenSpec change artifacts trackable even when change names include ignored words. +# Keep OpenSpec artifacts trackable even when names include ignored words. !/openspec/changes/ !/openspec/changes/** +!/openspec/specs/ +!/openspec/specs/** .ssh/ .direnv/ diff --git a/dimos/core/coordination/blueprints.py b/dimos/core/coordination/blueprints.py index f21ff3fe30..db28b4504f 100644 --- a/dimos/core/coordination/blueprints.py +++ b/dimos/core/coordination/blueprints.py @@ -28,6 +28,7 @@ from dimos.core.global_config import GlobalConfig from dimos.core.module import ModuleBase, is_module_type +from dimos.core.runtime_environment import RuntimeEnvironment, RuntimeEnvironmentRegistry from dimos.core.stream import In, Out from dimos.core.transport import PubSubTransport from dimos.spec.utils import Spec, is_spec @@ -142,7 +143,12 @@ def create(cls, module: type[ModuleBase], kwargs: dict[str, Any]) -> Self: # These fields cannot be pickled. -_PROXY_FIELDS = ("transport_map", "global_config_overrides", "remapping_map") +_PROXY_FIELDS = ( + "transport_map", + "global_config_overrides", + "remapping_map", + "runtime_placement_map", +) @dataclass(frozen=True) @@ -156,6 +162,12 @@ class Blueprint: remapping_map: Mapping[tuple[type[ModuleBase], str], str | type[ModuleBase] | type[Spec]] = ( field(default_factory=lambda: MappingProxyType({})) ) + runtime_environment_registry: RuntimeEnvironmentRegistry = field( + default_factory=RuntimeEnvironmentRegistry.with_current_process + ) + runtime_placement_map: Mapping[type[ModuleBase], str] = field( + default_factory=lambda: MappingProxyType({}) + ) requirement_checks: tuple[Callable[[], str | None], ...] = field(default_factory=tuple) configurator_checks: "tuple[SystemConfigurator, ...]" = field(default_factory=tuple) @@ -205,6 +217,16 @@ def remappings( remappings_dict[(module, old)] = new return replace(self, remapping_map=MappingProxyType(remappings_dict)) + def runtime_environments(self, *environments: RuntimeEnvironment) -> "Blueprint": + registry = self.runtime_environment_registry + for environment in environments: + registry = registry.register(environment) + return replace(self, runtime_environment_registry=registry) + + def runtime_placements(self, placements: Mapping[type[ModuleBase], str]) -> "Blueprint": + placement_dict = {**self.runtime_placement_map, **placements} + return replace(self, runtime_placement_map=MappingProxyType(placement_dict)) + def requirements(self, *checks: Callable[[], str | None]) -> "Blueprint": return replace(self, requirement_checks=self.requirement_checks + tuple(checks)) @@ -230,6 +252,12 @@ def autoconnect(*blueprints: Blueprint) -> Blueprint: all_remappings = dict( # type: ignore[var-annotated] reduce(operator.iadd, [list(x.remapping_map.items()) for x in blueprints], []) ) + runtime_registry = RuntimeEnvironmentRegistry.with_current_process() + for blueprint in blueprints: + runtime_registry = runtime_registry.merge(blueprint.runtime_environment_registry) + all_runtime_placements = dict( + reduce(operator.iadd, [list(x.runtime_placement_map.items()) for x in blueprints], []) + ) all_requirement_checks = tuple(check for bs in blueprints for check in bs.requirement_checks) all_configurator_checks = tuple(check for bs in blueprints for check in bs.configurator_checks) @@ -241,6 +269,8 @@ def autoconnect(*blueprints: Blueprint) -> Blueprint: transport_map=MappingProxyType(all_transports), global_config_overrides=MappingProxyType(all_config_overrides), remapping_map=MappingProxyType(all_remappings), + runtime_environment_registry=runtime_registry, + runtime_placement_map=MappingProxyType(all_runtime_placements), requirement_checks=all_requirement_checks, configurator_checks=all_configurator_checks, ) diff --git a/dimos/core/coordination/module_coordinator.py b/dimos/core/coordination/module_coordinator.py index a560152133..ddfffec497 100644 --- a/dimos/core/coordination/module_coordinator.py +++ b/dimos/core/coordination/module_coordinator.py @@ -16,6 +16,7 @@ from collections import defaultdict from collections.abc import Callable, Mapping, MutableMapping +from dataclasses import replace import importlib import inspect import shutil @@ -24,11 +25,13 @@ from typing import TYPE_CHECKING, Any, NamedTuple, cast from dimos.core.coordination.coordinator_rpc import CoordinatorRPC +from dimos.core.coordination.worker_launcher import VenvWorkerLauncher from dimos.core.coordination.worker_manager import WorkerManager from dimos.core.coordination.worker_manager_python import WorkerManagerPython from dimos.core.global_config import GlobalConfig, global_config from dimos.core.module import ModuleBase, ModuleSpec from dimos.core.resource import Resource +from dimos.core.runtime_environment import RuntimeEnvironmentRegistry from dimos.core.transport import LCMTransport, PubSubTransport, pLCMTransport from dimos.spec.utils import is_spec, spec_annotation_compliance, spec_structural_compliance from dimos.utils.generic import short_id @@ -68,6 +71,9 @@ def __init__( self._transport_registry: dict[tuple[str, type], PubSubTransport[Any]] = {} self._class_aliases: dict[type[ModuleBase], type[ModuleBase]] = {} self._module_transports: dict[type[ModuleBase], dict[str, PubSubTransport[Any]]] = {} + self._runtime_environment_registry = RuntimeEnvironmentRegistry.with_current_process() + self._runtime_placement_map: dict[type[ModuleBase], str] = {} + self._module_manager_keys: dict[type[ModuleBase], str] = {} self._started = False self._modules_lock = threading.RLock() self._coordinator_rpc: CoordinatorRPC | None = None @@ -147,7 +153,15 @@ def list_module_names(self) -> list[str]: return [cls.__name__ for cls in self._deployed_modules] def health_check(self) -> bool: - return all(m.health_check() for m in self._managers.values()) + return all(m.health_check() for m in self._active_managers().values()) + + def _active_managers(self) -> dict[str, WorkerManager]: + return { + key: manager + for key, manager in self._managers.items() + if key in self._module_manager_keys.values() + or (key == "python" and bool(cast("WorkerManagerPython", manager).workers)) + } @property def n_modules(self) -> int: @@ -166,32 +180,143 @@ def deploy( if not self._managers: raise ValueError("Trying to dimos.deploy before the client has started") - deployed_module = self._managers[module_class.deployment].deploy( - module_class, global_config, kwargs - ) + manager_key = self._manager_key_for_module(module_class) + kwargs = self._inject_native_runtime_registry(module_class, kwargs) + deployed_module = self._managers[manager_key].deploy(module_class, global_config, kwargs) with self._modules_lock: self._deployed_modules[module_class] = deployed_module + self._module_manager_keys[module_class] = manager_key return deployed_module # type: ignore[return-value] + def _manager_key_for_module(self, module_class: type[ModuleBase]) -> str: + if module_class.deployment != "python": + return module_class.deployment + env_name = self._runtime_placement_map.get(module_class) + if env_name is None: + return "python" + return self._ensure_venv_manager(env_name) + + def _ensure_venv_manager(self, env_name: str) -> str: + manager_key = f"python:{env_name}" + if manager_key in self._managers: + return manager_key + try: + material = self._runtime_environment_registry.resolve(env_name).resolve_python() + except Exception as exc: + raise RuntimeError( + f"Module placement references runtime environment {env_name!r}, but it could not " + "be resolved as a Python runtime capability. Register a Python runtime environment on the " + "blueprint with .runtime_environments(...)." + ) from exc + executable = str(material.python_executable) + if material.python_executable.is_absolute(): + executable_found = material.python_executable.exists() + else: + executable_found = shutil.which(executable) is not None + if not executable_found: + raise RuntimeError( + f"Runtime environment {env_name!r} Python capability references missing " + f"executable {executable!r}." + ) + manager = WorkerManagerPython( + g=self._global_config, + worker_launcher=VenvWorkerLauncher(python_executable=executable, env=material.env), + ) + if self._started: + try: + manager.start() + except Exception as exc: + manager.stop() + raise RuntimeError( + f"Failed to start runtime environment {env_name!r} Python capability " + f"with executable {executable!r}." + ) from exc + self._managers[manager_key] = manager + return manager_key + + def _inject_native_runtime_registry( + self, module_class: type[ModuleBase], kwargs: Mapping[str, Any] + ) -> dict[str, Any]: + from dimos.core.native_module import NativeModule + + result = dict(kwargs) + if issubclass(module_class, NativeModule): + result["runtime_environment_registry"] = self._runtime_environment_registry + return result + def deploy_parallel( - self, module_specs: list[ModuleSpec], blueprint_args: Mapping[str, Mapping[str, Any]] + self, + module_specs: list[ModuleSpec], + blueprint_args: Mapping[str, Mapping[str, Any]], + runtime_environment_registry: RuntimeEnvironmentRegistry | None = None, + runtime_placement_map: Mapping[type[ModuleBase], str] | None = None, ) -> list[ModuleProxy]: if not self._managers: raise ValueError("Not started") - # Group specs by deployment type, tracking original indices for reassembly - indices_by_deployment: dict[str, list[int]] = {} - specs_by_deployment: dict[str, list[ModuleSpec]] = {} - for index, spec in enumerate(module_specs): - # spec = (module_class, global_config, kwargs) - dep = spec[0].deployment - indices_by_deployment.setdefault(dep, []).append(index) - specs_by_deployment.setdefault(dep, []).append(spec) + if runtime_environment_registry is not None: + self._runtime_environment_registry = self._runtime_environment_registry.merge( + runtime_environment_registry + ) + previous_placements = dict(self._runtime_placement_map) + existing_manager_keys = set(self._managers) + module_classes = {spec[0] for spec in module_specs} + active_runtime_placement_map = ( + { + module_class: env_name + for module_class, env_name in runtime_placement_map.items() + if module_class in module_classes + } + if runtime_placement_map is not None + else None + ) + if active_runtime_placement_map is not None: + self._runtime_placement_map.update(active_runtime_placement_map) + + try: + # Group specs by deployment manager, tracking original indices for reassembly + indices_by_deployment: dict[str, list[int]] = {} + specs_by_deployment: dict[str, list[ModuleSpec]] = {} + effective_module_specs: list[ModuleSpec] = [] + for index, spec in enumerate(module_specs): + module_class, spec_global_config, kwargs = spec + dep = self._manager_key_for_module(module_class) + effective_kwargs = dict(kwargs) + effective_kwargs.update(blueprint_args.get(module_class.name, {})) + effective_kwargs = self._inject_native_runtime_registry( + module_class, effective_kwargs + ) + spec = (module_class, spec_global_config, effective_kwargs) + effective_module_specs.append(spec) + indices_by_deployment.setdefault(dep, []).append(index) + specs_by_deployment.setdefault(dep, []).append(spec) + except Exception: + self._runtime_placement_map = previous_placements + for key in set(self._managers) - existing_manager_keys: + self._stop_and_remove_manager(key) + raise results: list[Any] = [None] * len(module_specs) def _deploy_group(dep: str) -> None: - deployed = self._managers[dep].deploy_parallel(specs_by_deployment[dep], blueprint_args) + try: + deployed = self._managers[dep].deploy_parallel(specs_by_deployment[dep], {}) + except Exception as exc: + if dep.startswith("python:"): + env_name = dep.removeprefix("python:") + try: + executable = str( + self._runtime_environment_registry.resolve(env_name) + .resolve_python() + .python_executable + ) + except Exception: + executable = "" + raise RuntimeError( + f"Failed to deploy with runtime environment {env_name!r} Python " + f"capability using executable {executable!r}." + ) from exc + raise for index, module in zip(indices_by_deployment[dep], deployed, strict=True): results[index] = module @@ -199,16 +324,22 @@ def _deploy_group(dep: str) -> None: safe_thread_map(list(specs_by_deployment.keys()), _deploy_group) except: self.stop() + self._runtime_placement_map = previous_placements + for key in set(self._managers) - existing_manager_keys: + self._stop_and_remove_manager(key) raise with self._modules_lock: self._deployed_modules.update( { cls: mod - for (cls, _, _), mod in zip(module_specs, results, strict=True) + for (cls, _, _), mod in zip(effective_module_specs, results, strict=True) if mod is not None } ) + for (cls, _, _), mod in zip(effective_module_specs, results, strict=True): + if mod is not None: + self._module_manager_keys[cls] = self._manager_key_for_module(cls) return results def build_all_modules(self) -> None: @@ -392,6 +523,11 @@ def unload_module(self, module_class: type[ModuleBase]) -> None: self._unload_module(module_class) def _unload_module(self, module_class: type[ModuleBase]) -> None: + self._unload_module_impl(module_class, preserve_placement=False) + + def _unload_module_impl( + self, module_class: type[ModuleBase], *, preserve_placement: bool + ) -> None: module_class = self._resolve_class(module_class) if module_class not in self._deployed_modules: raise ValueError(f"{module_class.__name__} is not deployed") @@ -411,7 +547,10 @@ def _unload_module(self, module_class: type[ModuleBase]) -> None: exc_info=True, ) - python_wm = cast("WorkerManagerPython", self._managers["python"]) + manager_key = self._module_manager_keys.get( + module_class, self._manager_key_for_module(module_class) + ) + python_wm = cast("WorkerManagerPython", self._managers[manager_key]) try: python_wm.undeploy(proxy) except Exception: @@ -422,6 +561,9 @@ def _unload_module(self, module_class: type[ModuleBase]) -> None: ) del self._deployed_modules[module_class] + self._module_manager_keys.pop(module_class, None) + if not preserve_placement: + self._clear_runtime_placement_aliases(module_class) self._deployed_atoms.pop(module_class, None) self._module_transports.pop(module_class, None) self._class_aliases = { @@ -432,6 +574,27 @@ def _unload_module(self, module_class: type[ModuleBase]) -> None: for key, target in self._resolved_module_refs.items() if key[0] is not module_class and target is not module_class } + if ( + manager_key.startswith("python:") + and manager_key not in self._module_manager_keys.values() + ): + self._stop_and_remove_manager(manager_key) + + def _clear_runtime_placement_aliases(self, module_class: type[ModuleBase]) -> None: + """Clear placement for a class and stale handles that alias to it.""" + self._runtime_placement_map.pop(module_class, None) + for alias_class, resolved_class in list(self._class_aliases.items()): + if resolved_class is module_class: + self._runtime_placement_map.pop(alias_class, None) + + def _stop_and_remove_manager(self, manager_key: str) -> None: + manager = self._managers.pop(manager_key, None) + if manager is None: + return + try: + manager.stop() + except Exception: + logger.error("Error stopping manager", manager=manager_key, exc_info=True) def restart_module_by_class_name( self, @@ -478,7 +641,8 @@ def _restart_module( ) old_atom = self._deployed_atoms[module_class] - kwargs = dict(old_atom.kwargs) + kwargs = self._inject_native_runtime_registry(module_class, old_atom.kwargs) + runtime_env_name = self._runtime_placement_map.get(module_class) saved_transports = dict(self._module_transports.get(module_class, {})) inbound_refs = [ (consumer, ref_name) @@ -491,7 +655,7 @@ def _restart_module( if consumer is module_class ] - self.unload_module(module_class) + self._unload_module_impl(module_class, preserve_placement=True) if reload_source: source_mod = sys.modules.get(module_class.__module__) @@ -507,10 +671,17 @@ def _restart_module( if self._class_aliases[old_cls] is module_class: self._class_aliases[old_cls] = new_class self._class_aliases[module_class] = new_class + if runtime_env_name is not None: + self._runtime_placement_map.pop(module_class, None) + self._runtime_placement_map[new_class] = runtime_env_name - python_wm = cast("WorkerManagerPython", self._managers["python"]) + manager_key = self._module_manager_keys.get(new_class) or self._manager_key_for_module( + new_class + ) + python_wm = cast("WorkerManagerPython", self._managers[manager_key]) new_proxy = python_wm.deploy_fresh(new_class, self._global_config, kwargs) self._deployed_modules[new_class] = new_proxy + self._module_manager_keys[new_class] = manager_key new_bp = new_class.blueprint(**kwargs) new_atom = new_bp.active_blueprints[0] @@ -687,10 +858,20 @@ def _deploy_all_modules( for bp in blueprint.active_blueprints: module_specs.append((bp.module, gc, bp.kwargs.copy())) - module_coordinator.deploy_parallel(module_specs, blueprint_args) + module_coordinator.deploy_parallel( + module_specs, + blueprint_args, + runtime_environment_registry=blueprint.runtime_environment_registry, + runtime_placement_map=blueprint.runtime_placement_map, + ) for bp in blueprint.active_blueprints: - module_coordinator._deployed_atoms[bp.module] = bp + effective_kwargs = dict(bp.kwargs) + effective_kwargs.update(blueprint_args.get(bp.module.name, {})) + effective_kwargs = module_coordinator._inject_native_runtime_registry( + bp.module, effective_kwargs + ) + module_coordinator._deployed_atoms[bp.module] = replace(bp, kwargs=effective_kwargs) def _ref_msg(module_name: str, ref: object, spec_name: str, detail: str) -> str: diff --git a/dimos/core/coordination/python_worker.py b/dimos/core/coordination/python_worker.py index c0d5d8c9c4..9471122e52 100644 --- a/dimos/core/coordination/python_worker.py +++ b/dimos/core/coordination/python_worker.py @@ -24,6 +24,11 @@ import traceback from typing import TYPE_CHECKING, Any +from dimos.core.coordination.worker_launcher import ( + ForkserverWorkerLauncher, + WorkerLauncher, + WorkerProcessHandle, +) from dimos.core.coordination.worker_messages import ( CallMethodRequest, DeployModuleRequest, @@ -93,7 +98,7 @@ class Actor: def __init__( self, - conn: Connection | None, + conn: WorkerProcessHandle | Connection | None, module_class: type[ModuleBase], worker_id: int, module_id: int = 0, @@ -161,12 +166,12 @@ def reset_forkserver_context() -> None: class PythonWorker: - def __init__(self) -> None: + def __init__(self, launcher: WorkerLauncher | None = None) -> None: self._lock = threading.Lock() self._modules: dict[int, Actor] = {} self._reserved: int = 0 - self._process: Any = None - self._conn: Connection | None = None + self._handle: WorkerProcessHandle | None = None + self._launcher = launcher or ForkserverWorkerLauncher() self._worker_id: int = _worker_ids.next() self.dedicated: bool = False @@ -177,17 +182,9 @@ def module_count(self) -> int: @property def pid(self) -> int | None: """PID of the worker process, or ``None`` if not alive.""" - if self._process is None: - return None - try: - # Signal 0 just checks if the process is alive. - pid: int | None = self._process.pid - if pid is None: - return None - os.kill(pid, 0) - return pid - except OSError: + if self._handle is None: return None + return self._handle.pid @property def worker_id(self) -> int: @@ -202,16 +199,7 @@ def reserve_slot(self) -> None: self._reserved += 1 def start_process(self) -> None: - ctx = get_forkserver_context() - parent_conn, child_conn = ctx.Pipe() - self._conn = parent_conn - - self._process = ctx.Process( - target=_worker_entrypoint, - args=(child_conn, self._worker_id), - daemon=True, - ) - self._process.start() + self._handle = self._launcher.launch(self._worker_id) def deploy_module( self, @@ -219,7 +207,7 @@ def deploy_module( global_config: GlobalConfig = global_config, kwargs: dict[str, Any] | None = None, ) -> Actor: - if self._conn is None: + if self._handle is None: raise RuntimeError("Worker process not started") kwargs = kwargs or {} @@ -229,13 +217,13 @@ def deploy_module( request = DeployModuleRequest(module_id=module_id, module_class=module_class, kwargs=kwargs) try: with self._lock: - self._conn.send(request) - response: WorkerResponse = self._conn.recv() + self._handle.send(request) + response: WorkerResponse = self._handle.recv() if response.error: raise RuntimeError(f"Failed to deploy module: {response.error}") - actor = Actor(self._conn, module_class, self._worker_id, module_id, self._lock) + actor = Actor(self._handle, module_class, self._worker_id, module_id, self._lock) actor.set_ref(actor).result() self._modules[module_id] = actor @@ -251,12 +239,12 @@ def deploy_module( def undeploy_module(self, module_id: int) -> None: """Stop and remove a single module from the worker process.""" - if self._conn is None: + if self._handle is None: raise RuntimeError("Worker process not started") with self._lock: - self._conn.send(UndeployModuleRequest(module_id=module_id)) - response: WorkerResponse = self._conn.recv() + self._handle.send(UndeployModuleRequest(module_id=module_id)) + response: WorkerResponse = self._handle.recv() if response.error: raise RuntimeError(f"Failed to undeploy module: {response.error}") @@ -264,22 +252,22 @@ def undeploy_module(self, module_id: int) -> None: self._modules.pop(module_id, None) def suppress_console(self) -> None: - if self._conn is None: + if self._handle is None: return try: with self._lock: - self._conn.send(SuppressConsoleRequest()) - self._conn.recv() + self._handle.send(SuppressConsoleRequest()) + self._handle.recv() except (BrokenPipeError, EOFError, ConnectionResetError): pass def shutdown(self) -> None: - if self._conn is not None: + if self._handle is not None: try: with self._lock: - self._conn.send(ShutdownRequest()) - if self._conn.poll(timeout=5): - self._conn.recv() + self._handle.send(ShutdownRequest()) + if self._handle.poll(timeout=5): + self._handle.recv() else: logger.warning( "Worker did not respond to shutdown within 5s, closing pipe.", @@ -288,19 +276,17 @@ def shutdown(self) -> None: except (BrokenPipeError, EOFError, ConnectionResetError): pass finally: - self._conn.close() - self._conn = None + self._handle.close() - if self._process is not None: - self._process.join(timeout=5) - if self._process.is_alive(): + self._handle.join(timeout=5) + if self._handle.is_alive(): logger.warning( "Worker still alive after 5s, terminating.", worker_id=self._worker_id, ) - self._process.terminate() - self._process.join(timeout=1) - self._process = None + self._handle.terminate() + self._handle.join(timeout=1) + self._handle = None def _suppress_console_output() -> None: diff --git a/dimos/core/coordination/test_blueprints.py b/dimos/core/coordination/test_blueprints.py index d8ada76b20..8f3948781f 100644 --- a/dimos/core/coordination/test_blueprints.py +++ b/dimos/core/coordination/test_blueprints.py @@ -13,6 +13,7 @@ # limitations under the License. +from pathlib import Path import pickle from types import MappingProxyType from typing import Protocol, get_type_hints @@ -35,6 +36,7 @@ ) from dimos.core.core import rpc from dimos.core.module import Module +from dimos.core.runtime_environment import PythonVenvRuntimeEnvironment from dimos.core.stream import In, Out from dimos.core.transport import LCMTransport from dimos.spec.utils import Spec @@ -237,7 +239,12 @@ def test_blueprint_pickle_roundtrip() -> None: restored = pickle.loads(pickle.dumps(blueprint)) assert restored == blueprint - for name in ("transport_map", "global_config_overrides", "remapping_map"): + for name in ( + "transport_map", + "global_config_overrides", + "remapping_map", + "runtime_placement_map", + ): assert isinstance(getattr(restored, name), MappingProxyType) assert dict(restored.global_config_overrides) == {"option1": True, "option2": 42} assert restored.remapping_map[(ModuleA, "module_a")] is ModuleB @@ -245,6 +252,32 @@ def test_blueprint_pickle_roundtrip() -> None: restored.global_config_overrides["x"] = 1 +def test_runtime_environments_and_placements_merge_by_module_class() -> None: + sensors = PythonVenvRuntimeEnvironment( + name="sensors", + python_executable=Path("/opt/sensors/bin/python"), + env={"DIMOS_TEST": "1"}, + ) + cameras = PythonVenvRuntimeEnvironment( + name="cameras", + python_executable=Path("/opt/cameras/bin/python"), + ) + + first = ( + ModuleA.blueprint().runtime_environments(sensors).runtime_placements({ModuleA: "sensors"}) + ) + second = ( + ModuleB.blueprint().runtime_environments(cameras).runtime_placements({ModuleA: "cameras"}) + ) + + merged = autoconnect(first, second) + + assert merged.runtime_environment_registry.resolve("sensors") is sensors + assert merged.runtime_environment_registry.resolve("cameras") is cameras + assert merged.runtime_placement_map[ModuleA] == "cameras" + assert ModuleB not in merged.runtime_placement_map + + def test_active_blueprints_filters_disabled() -> None: blueprint = autoconnect(ModuleA.blueprint(), ModuleB.blueprint()).disabled_modules(ModuleA) diff --git a/dimos/core/coordination/test_module_coordinator.py b/dimos/core/coordination/test_module_coordinator.py index c1baad17b2..145e9308f1 100644 --- a/dimos/core/coordination/test_module_coordinator.py +++ b/dimos/core/coordination/test_module_coordinator.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path +import sys from types import MappingProxyType from typing import Protocol @@ -37,6 +39,7 @@ from dimos.core.core import rpc from dimos.core.global_config import GlobalConfig from dimos.core.module import Module +from dimos.core.runtime_environment import PythonVenvRuntimeEnvironment from dimos.core.stream import In, Out from dimos.msgs.sensor_msgs.Image import Image from dimos.spec.utils import Spec @@ -582,6 +585,193 @@ def test_load_blueprint_auto_scales_empty_pool(dynamic_coordinator) -> None: assert dynamic_coordinator.get_instance(ModuleA).data1.transport is not None +def _sys_python_env(name: str) -> PythonVenvRuntimeEnvironment: + return PythonVenvRuntimeEnvironment(name=name, python_executable=Path(sys.executable)) + + +def test_unplaced_modules_use_default_pool(build_coordinator) -> None: + coordinator = build_coordinator(ModuleA.blueprint()) + + assert coordinator._module_manager_keys[ModuleA] == "python" + assert "python:env-a" not in coordinator._managers + + +def test_same_named_env_modules_share_venv_pool(build_coordinator) -> None: + blueprint = ( + autoconnect(ModuleA.blueprint(), ModuleB.blueprint()) + .global_config(n_workers=1) + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({ModuleA: "env-a", ModuleB: "env-a"}) + ) + + coordinator = build_coordinator(blueprint) + + assert coordinator._module_manager_keys[ModuleA] == "python:env-a" + assert coordinator._module_manager_keys[ModuleB] == "python:env-a" + manager = coordinator._managers["python:env-a"] + assert isinstance(manager, WorkerManagerPython) + assert len(manager.workers) == 1 + + +def test_distinct_named_env_modules_use_distinct_venv_pools(build_coordinator) -> None: + blueprint = ( + autoconnect(ModuleA.blueprint(), ModuleB.blueprint()) + .runtime_environments(_sys_python_env("env-a"), _sys_python_env("env-b")) + .runtime_placements({ModuleA: "env-a", ModuleB: "env-b"}) + ) + + coordinator = build_coordinator(blueprint) + + assert coordinator._module_manager_keys[ModuleA] == "python:env-a" + assert coordinator._module_manager_keys[ModuleB] == "python:env-b" + assert coordinator._managers["python:env-a"] is not coordinator._managers["python:env-b"] + + +def test_placed_modules_preserve_stream_refs_and_rpc(build_coordinator) -> None: + blueprint = ( + autoconnect(ModuleA.blueprint(), ModuleB.blueprint(), ModuleC.blueprint()) + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({ModuleA: "env-a", ModuleB: "env-a"}) + ) + + coordinator = build_coordinator(blueprint) + module_a = coordinator.get_instance(ModuleA) + module_b = coordinator.get_instance(ModuleB) + module_c = coordinator.get_instance(ModuleC) + + assert module_a.data1.transport.topic == module_b.data1.transport.topic + assert module_b.data3.transport.topic == module_c.data3.transport.topic + assert module_b.what_is_as_name() == "A, Module A" + + +def test_dynamic_load_unload_restart_preserves_placement(dynamic_coordinator) -> None: + blueprint = ( + ModuleA.blueprint() + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({ModuleA: "env-a"}) + ) + + dynamic_coordinator.load_blueprint(blueprint) + assert dynamic_coordinator._module_manager_keys[ModuleA] == "python:env-a" + + dynamic_coordinator.restart_module(ModuleA, reload_source=False) + assert dynamic_coordinator._module_manager_keys[ModuleA] == "python:env-a" + + dynamic_coordinator.unload_module(ModuleA) + assert ModuleA not in dynamic_coordinator._module_manager_keys + + +def test_unload_clears_placement_for_later_unplaced_load(dynamic_coordinator) -> None: + blueprint = ( + ModuleA.blueprint() + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({ModuleA: "env-a"}) + ) + + dynamic_coordinator.load_blueprint(blueprint) + assert dynamic_coordinator._module_manager_keys[ModuleA] == "python:env-a" + + dynamic_coordinator.unload_module(ModuleA) + dynamic_coordinator.load_blueprint(ModuleA.blueprint()) + + assert dynamic_coordinator._module_manager_keys[ModuleA] == "python" + + +def test_unloading_last_venv_module_leaves_health_check_healthy(dynamic_coordinator) -> None: + blueprint = ( + autoconnect(ModuleA.blueprint(), ModuleC.blueprint()) + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({ModuleA: "env-a"}) + ) + + dynamic_coordinator.load_blueprint(blueprint) + dynamic_coordinator.unload_module(ModuleA) + + assert "python:env-a" not in dynamic_coordinator._managers + assert dynamic_coordinator.health_check() is True + + +def test_unloading_last_venv_module_removes_idle_worker_manager() -> None: + coordinator = ModuleCoordinator(g=GlobalConfig(n_workers=2, viewer="none")) + coordinator.start() + try: + blueprint = ( + ModuleA.blueprint() + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({ModuleA: "env-a"}) + ) + + coordinator.load_blueprint(blueprint) + manager = coordinator._managers["python:env-a"] + assert isinstance(manager, WorkerManagerPython) + assert len(manager.workers) == 2 + + coordinator.unload_module(ModuleA) + + assert "python:env-a" not in coordinator._managers + assert coordinator.health_check() is True + finally: + coordinator.stop() + + +def test_absent_placement_does_not_affect_later_unplaced_load(dynamic_coordinator) -> None: + blueprint = ( + ModuleA.blueprint() + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({ModuleB: "env-a"}) + ) + + dynamic_coordinator.load_blueprint(blueprint) + dynamic_coordinator.load_blueprint(ModuleB.blueprint()) + + assert dynamic_coordinator._module_manager_keys[ModuleB] == "python" + assert "python:env-a" not in dynamic_coordinator._managers + + +def test_disabled_placement_does_not_affect_later_unplaced_load(dynamic_coordinator) -> None: + blueprint = ( + autoconnect(ModuleA.blueprint(), ModuleB.blueprint()) + .disabled_modules(ModuleB) + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({ModuleB: "env-a"}) + ) + + dynamic_coordinator.load_blueprint(blueprint) + dynamic_coordinator.load_blueprint(ModuleB.blueprint()) + + assert dynamic_coordinator._module_manager_keys[ModuleB] == "python" + assert "python:env-a" not in dynamic_coordinator._managers + + +def test_placed_module_unknown_env_raises_clear_error(dynamic_coordinator) -> None: + blueprint = ModuleA.blueprint().runtime_placements({ModuleA: "missing-env"}) + + with pytest.raises(RuntimeError, match="Register a Python runtime environment"): + dynamic_coordinator.load_blueprint(blueprint) + + +def test_missing_venv_executable_error_includes_env_and_does_not_linger( + dynamic_coordinator, tmp_path +) -> None: + missing_python = tmp_path / "missing-python" + blueprint = ( + ModuleA.blueprint() + .runtime_environments( + PythonVenvRuntimeEnvironment(name="bad-env", python_executable=missing_python) + ) + .runtime_placements({ModuleA: "bad-env"}) + ) + + with pytest.raises(RuntimeError) as exc_info: + dynamic_coordinator.load_blueprint(blueprint) + + message = str(exc_info.value) + assert "bad-env" in message + assert str(missing_python) in message + assert "Python capability" in message + assert "python:bad-env" not in dynamic_coordinator._managers + + def test_check_requirements_failure(mocker) -> None: """A failing requirement check causes sys.exit.""" mocker.patch("dimos.core.coordination.module_coordinator.sys.exit", side_effect=SystemExit(1)) @@ -754,6 +944,37 @@ def test_unload_after_reload_restart(dynamic_coordinator, mocker) -> None: assert dynamic_coordinator.get_instance(ModuleA) is None +def test_placed_reload_restart_unload_clears_old_class_placement( + dynamic_coordinator, mocker +) -> None: + """A reload restart must not leave stale placement on the old class handle.""" + original_class = ModuleA + blueprint = ( + original_class.blueprint() + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({original_class: "env-a"}) + ) + dynamic_coordinator.load_blueprint(blueprint) + + side_effect, new_class = _mock_reload_producing_new_class(original_class) + mocker.patch( + "dimos.core.coordination.module_coordinator.importlib.reload", + side_effect=side_effect, + ) + + dynamic_coordinator.restart_module(original_class, reload_source=True) + assert all(cls is not original_class for cls in dynamic_coordinator._runtime_placement_map) + assert dynamic_coordinator._runtime_placement_map[new_class] == "env-a" + + dynamic_coordinator.unload_module(original_class) + assert all(cls is not original_class for cls in dynamic_coordinator._runtime_placement_map) + assert all(cls is not new_class for cls in dynamic_coordinator._runtime_placement_map) + + setattr(sys.modules[original_class.__module__], original_class.__name__, original_class) + dynamic_coordinator.load_blueprint(original_class.blueprint()) + assert dynamic_coordinator._module_manager_keys[original_class] == "python" + + def test_restart_preserves_remapped_streams(dynamic_coordinator) -> None: """Restart reconnects streams that were remapped during initial load.""" bp = autoconnect( diff --git a/dimos/core/coordination/test_worker.py b/dimos/core/coordination/test_worker.py index c606dcda73..60b6501fd1 100644 --- a/dimos/core/coordination/test_worker.py +++ b/dimos/core/coordination/test_worker.py @@ -12,10 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys +import time from typing import TYPE_CHECKING +import psutil import pytest +from dimos.core.coordination.python_worker import PythonWorker, reset_forkserver_context +from dimos.core.coordination.worker_launcher import VenvWorkerLauncher from dimos.core.coordination.worker_manager_python import WorkerManagerPython from dimos.core.core import rpc from dimos.core.global_config import GlobalConfig, global_config @@ -89,6 +94,19 @@ def start(self) -> None: pass +class NoisyModule(Module): + @rpc + def start(self) -> None: + print("stdout noise from worker") + print("stderr noise from worker", file=sys.stderr) + + @rpc + def ping(self) -> str: + print("stdout rpc noise from worker") + print("stderr rpc noise from worker", file=sys.stderr) + return "pong" + + class AnotherHeavyModule(Module): dedicated_worker = True @@ -114,6 +132,73 @@ def _create(n_workers): manager.stop() +@pytest.mark.skipif_macos_bug +def test_venv_worker_launch_deploy_rpc_shutdown() -> None: + manager = WorkerManagerPython( + g=GlobalConfig(n_workers=1), + worker_launcher=VenvWorkerLauncher(sys.executable, startup_timeout=5.0), + ) + try: + module = manager.deploy(SimpleModule, global_config, {}) + module.start() + assert module.increment() == 1 + assert module.increment() == 2 + assert module.get_counter() == 2 + module.stop() + finally: + manager.stop() + + +def test_venv_worker_missing_python_executable() -> None: + worker = PythonWorker(launcher=VenvWorkerLauncher("/does/not/exist/python")) + + with pytest.raises(FileNotFoundError, match="Python executable"): + worker.start_process() + + +@pytest.mark.skipif_macos_bug +def test_venv_worker_connection_timeout_cleanup(tmp_path) -> None: + sleeper = tmp_path / "sleepy-python" + sleeper.write_text("#!/bin/sh\nsleep 30\n") + sleeper.chmod(0o755) + worker = PythonWorker(launcher=VenvWorkerLauncher(str(sleeper), startup_timeout=0.2)) + + with pytest.raises(TimeoutError, match="Timed out"): + worker.start_process() + + time.sleep(0.2) + assert not any( + "sleepy-python" in " ".join(proc.info.get("cmdline") or []) + for proc in psutil.process_iter(["cmdline"]) + ) + + +@pytest.mark.skipif_macos_bug +def test_venv_worker_stdout_stderr_noise_does_not_corrupt_control() -> None: + manager = WorkerManagerPython( + g=GlobalConfig(n_workers=1), + worker_launcher=VenvWorkerLauncher(sys.executable, startup_timeout=5.0), + ) + try: + module = manager.deploy(NoisyModule, global_config, {}) + module.start() + assert module.ping() == "pong" + module.stop() + finally: + manager.stop() + + +@pytest.mark.skipif_macos_bug +def test_venv_worker_startup_import_error_propagates(tmp_path) -> None: + failing_python = tmp_path / "failing-python" + failing_python.write_text("#!/bin/sh\nexit 42\n") + failing_python.chmod(0o755) + worker = PythonWorker(launcher=VenvWorkerLauncher(str(failing_python), startup_timeout=1.0)) + + with pytest.raises(RuntimeError, match="exit code 42"): + worker.start_process() + + @pytest.mark.skipif_macos_bug def test_worker_manager_basic(create_worker_manager): worker_manager = create_worker_manager(n_workers=2) @@ -132,6 +217,31 @@ def test_worker_manager_basic(create_worker_manager): module.stop() +@pytest.mark.skipif_macos_bug +def test_worker_manager_default_forkserver_lifecycle() -> None: + manager = WorkerManagerPython(g=GlobalConfig(n_workers=1)) + try: + manager.start() + worker = manager.workers[0] + pid = worker.pid + assert pid is not None + + module = manager.deploy(SimpleModule, global_config, {}) + module.start() + assert module.increment() == 1 + assert module.get_counter() == 1 + module.stop() + + worker.shutdown() + deadline = time.monotonic() + 3.0 + while psutil.pid_exists(pid) and time.monotonic() < deadline: + time.sleep(0.05) + assert not psutil.pid_exists(pid) + finally: + manager.stop() + reset_forkserver_context() + + @pytest.mark.skipif_macos_bug def test_worker_manager_multiple_different_modules(create_worker_manager): worker_manager = create_worker_manager(n_workers=2) diff --git a/dimos/core/coordination/venv_worker_entrypoint.py b/dimos/core/coordination/venv_worker_entrypoint.py new file mode 100644 index 0000000000..b06f70fe52 --- /dev/null +++ b/dimos/core/coordination/venv_worker_entrypoint.py @@ -0,0 +1,34 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import argparse +from multiprocessing.connection import Client + +from dimos.core.coordination.python_worker import _worker_entrypoint + + +def main() -> None: + parser = argparse.ArgumentParser(description="DimOS venv worker entrypoint") + parser.add_argument("--address", required=True) + parser.add_argument("--authkey-hex", required=True) + parser.add_argument("--worker-id", required=True, type=int) + args = parser.parse_args() + + conn = Client(args.address, family="AF_UNIX", authkey=bytes.fromhex(args.authkey_hex)) + _worker_entrypoint(conn, args.worker_id) + + +if __name__ == "__main__": + main() diff --git a/dimos/core/coordination/worker_launcher.py b/dimos/core/coordination/worker_launcher.py new file mode 100644 index 0000000000..435866c44b --- /dev/null +++ b/dimos/core/coordination/worker_launcher.py @@ -0,0 +1,220 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from dataclasses import dataclass +from multiprocessing.connection import Connection, Listener, wait +import os +import secrets +import subprocess +import tempfile +import time +from typing import Protocol + +from dimos.core.coordination.worker_messages import WorkerRequest, WorkerResponse + + +class WorkerProcessHandle(Protocol): + @property + def pid(self) -> int | None: ... + + def send(self, request: WorkerRequest) -> None: ... + + def recv(self) -> WorkerResponse: ... + + def poll(self, timeout: float) -> bool: ... + + def close(self) -> None: ... + + def join(self, timeout: float) -> None: ... + + def is_alive(self) -> bool: ... + + def terminate(self) -> None: ... + + +class WorkerLauncher(Protocol): + def launch(self, worker_id: int) -> WorkerProcessHandle: ... + + +@dataclass +class ForkserverWorkerProcessHandle: + process: object + connection: object + + @property + def pid(self) -> int | None: + pid = getattr(self.process, "pid", None) + if pid is None: + return None + try: + os.kill(pid, 0) + return pid + except OSError: + return None + + def send(self, request: WorkerRequest) -> None: + self.connection.send(request) # type: ignore[attr-defined] + + def recv(self) -> WorkerResponse: + return self.connection.recv() # type: ignore[attr-defined,no-any-return] + + def poll(self, timeout: float) -> bool: + return bool(self.connection.poll(timeout=timeout)) # type: ignore[attr-defined] + + def close(self) -> None: + self.connection.close() # type: ignore[attr-defined] + + def join(self, timeout: float) -> None: + self.process.join(timeout=timeout) # type: ignore[attr-defined] + + def is_alive(self) -> bool: + return bool(self.process.is_alive()) # type: ignore[attr-defined] + + def terminate(self) -> None: + self.process.terminate() # type: ignore[attr-defined] + + +class ForkserverWorkerLauncher: + def launch(self, worker_id: int) -> WorkerProcessHandle: + from dimos.core.coordination.python_worker import _worker_entrypoint, get_forkserver_context + + ctx = get_forkserver_context() + parent_conn, child_conn = ctx.Pipe() + process = ctx.Process(target=_worker_entrypoint, args=(child_conn, worker_id), daemon=True) + process.start() + return ForkserverWorkerProcessHandle(process=process, connection=parent_conn) + + +@dataclass +class VenvWorkerProcessHandle: + process: subprocess.Popen[bytes] + connection: Connection + + @property + def pid(self) -> int | None: + if self.process.poll() is not None: + return None + return self.process.pid + + def send(self, request: WorkerRequest) -> None: + self.connection.send(request) + + def recv(self) -> WorkerResponse: + return self.connection.recv() + + def poll(self, timeout: float) -> bool: + return bool(self.connection.poll(timeout=timeout)) + + def close(self) -> None: + self.connection.close() + + def join(self, timeout: float) -> None: + try: + self.process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + pass + + def is_alive(self) -> bool: + return self.process.poll() is None + + def terminate(self) -> None: + self.process.terminate() + + +class VenvWorkerLauncher: + def __init__( + self, + python_executable: str, + env: dict[str, str] | None = None, + startup_timeout: float = 10.0, + ) -> None: + self.python_executable = python_executable + self.env = env or {} + self.startup_timeout = startup_timeout + + def launch(self, worker_id: int) -> WorkerProcessHandle: + if not os.path.isfile(self.python_executable) or not os.access( + self.python_executable, os.X_OK + ): + raise FileNotFoundError( + f"Python executable not found or not executable: {self.python_executable}" + ) + + with tempfile.TemporaryDirectory(prefix="dimos-venv-worker-") as temp_dir: + address = os.path.join(temp_dir, "worker.sock") + authkey = secrets.token_bytes(32) + listener = Listener(address=address, family="AF_UNIX", authkey=authkey) + process: subprocess.Popen[bytes] | None = None + try: + child_env = os.environ.copy() + child_env.update(self.env) + process = subprocess.Popen( + [ + self.python_executable, + "-m", + "dimos.core.coordination.venv_worker_entrypoint", + "--address", + address, + "--authkey-hex", + authkey.hex(), + "--worker-id", + str(worker_id), + ], + env=child_env, + ) + conn = self._accept_with_timeout(listener, process) + return VenvWorkerProcessHandle(process=process, connection=conn) + except Exception: + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + raise + finally: + listener.close() + + def _accept_with_timeout( + self, listener: Listener, process: subprocess.Popen[bytes] + ) -> Connection: + deadline = time.monotonic() + self.startup_timeout + while True: + returncode = process.poll() + if returncode is not None: + command = str(process.args) + raise RuntimeError( + "Venv worker exited before connecting " + f"with exit code {returncode}. Command: {command}" + ) + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"Timed out waiting {self.startup_timeout}s for venv worker to connect" + ) + + ready = wait([listener._listener._socket], timeout=min(0.05, remaining)) # type: ignore[attr-defined] + if ready: + break + + result = listener.accept() + if process.poll() is not None: + result.close() + raise RuntimeError( + f"Venv worker exited during startup with exit code {process.returncode}" + ) + return result diff --git a/dimos/core/coordination/worker_manager_python.py b/dimos/core/coordination/worker_manager_python.py index 4963db2dac..df045416fc 100644 --- a/dimos/core/coordination/worker_manager_python.py +++ b/dimos/core/coordination/worker_manager_python.py @@ -14,10 +14,11 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from typing import TYPE_CHECKING, Any from dimos.core.coordination.python_worker import PythonWorker +from dimos.core.coordination.worker_launcher import WorkerLauncher from dimos.core.global_config import GlobalConfig from dimos.core.module import ModuleBase, ModuleSpec from dimos.core.rpc_client import ModuleProxyProtocol, RPCClient @@ -33,10 +34,19 @@ class WorkerManagerPython: deployment_identifier: str = "python" - def __init__(self, g: GlobalConfig) -> None: + def __init__( + self, + g: GlobalConfig, + worker_launcher: WorkerLauncher | None = None, + worker_factory: Callable[[], PythonWorker] | None = None, + ) -> None: + if worker_launcher is not None and worker_factory is not None: + raise ValueError("Pass either worker_launcher or worker_factory, not both") self._cfg = g self._n_workers = g.n_workers self._workers: list[PythonWorker] = [] + self._worker_launcher = worker_launcher + self._worker_factory = worker_factory self._closed = False self._started = False self._stats_monitor: StatsMonitor | None = None @@ -46,7 +56,7 @@ def start(self) -> None: return self._started = True for _ in range(self._n_workers): - worker = PythonWorker() + worker = self._create_worker() worker.start_process() self._workers.append(worker) logger.info("Worker pool started.", n_workers=self._n_workers) @@ -64,7 +74,7 @@ def add_workers(self, n: int) -> None: if not self._started: raise RuntimeError("WorkerManager not started; call start() first") for _ in range(n): - worker = PythonWorker() + worker = self._create_worker() worker.start_process() self._workers.append(worker) self._n_workers += n @@ -104,7 +114,7 @@ def deploy_fresh( if not self._started: self.start() - worker = PythonWorker() + worker = self._create_worker() worker.start_process() self._workers.append(worker) self._n_workers += 1 @@ -235,6 +245,11 @@ def _select_worker(self, dedicated: bool = False) -> PythonWorker: return self._workers[-1] return min(candidates, key=lambda w: w.module_count) + def _create_worker(self) -> PythonWorker: + if self._worker_factory is not None: + return self._worker_factory() + return PythonWorker(launcher=self._worker_launcher) + def _ensure_capacity_for_dedicated(self, specs: Iterable[ModuleSpec]) -> None: """Grow the pool so non-dedicated workers >= dedicated workers. diff --git a/dimos/core/native_module.py b/dimos/core/native_module.py index 6e34a2ffd5..fc8a8e66c6 100644 --- a/dimos/core/native_module.py +++ b/dimos/core/native_module.py @@ -60,6 +60,7 @@ class MyCppModule(NativeModule): from dimos.core.core import rpc from dimos.core.global_config import global_config from dimos.core.module import Module, ModuleConfig +from dimos.core.runtime_environment import RuntimeEnvironmentRegistry from dimos.utils.logging_config import setup_logger if sys.platform.startswith("linux"): @@ -115,9 +116,13 @@ class LogFormat(enum.Enum): class NativeModuleConfig(ModuleConfig): """Configuration for a native (C/C++) subprocess module.""" - executable: str + executable: str | None = None build_command: str | None = None cwd: str | None = None + runtime_environment: str | None = None + runtime_environment_registry: RuntimeEnvironmentRegistry | None = Field( + default=None, exclude=True, repr=False + ) extra_args: list[str] = Field(default_factory=list) extra_env: dict[str, str] = Field(default_factory=dict) shutdown_timeout: float = DEFAULT_THREAD_JOIN_TIMEOUT @@ -195,12 +200,59 @@ def _module_label(self) -> str: def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self._stop_lock = threading.Lock() + try: + self._apply_runtime_environment() - if self.config.cwd is not None and not Path(self.config.cwd).is_absolute(): - base_dir = Path(inspect.getfile(type(self))).resolve().parent - self.config.cwd = str(base_dir / self.config.cwd) - if not Path(self.config.executable).is_absolute() and self.config.cwd is not None: - self.config.executable = str(Path(self.config.cwd) / self.config.executable) + if self.config.executable is None: + raise ValueError( + f"{type(self).__name__} requires an executable or a native " + "runtime_environment that provides one." + ) + + if self.config.cwd is not None and not Path(self.config.cwd).is_absolute(): + base_dir = Path(inspect.getfile(type(self))).resolve().parent + self.config.cwd = str(base_dir / self.config.cwd) + if not Path(self.config.executable).is_absolute() and self.config.cwd is not None: + self.config.executable = str(Path(self.config.cwd) / self.config.executable) + except Exception: + self._close_module() + raise + + def _apply_runtime_environment(self) -> None: + env_name = self.config.runtime_environment + if env_name is None: + return + registry = self.config.runtime_environment_registry + if registry is None: + raise RuntimeError( + f"Native runtime environment {env_name!r} requested by {type(self).__name__}, " + "but no RuntimeEnvironmentRegistry was provided. Register it on the blueprint " + "with .runtime_environments(...)." + ) + try: + material = registry.resolve(env_name).resolve_native() + except Exception as exc: + raise RuntimeError( + f"Native runtime environment {env_name!r} requested by {type(self).__name__} " + "could not be resolved as a native capability. Register a native runtime " + "environment on the blueprint with .runtime_environments(...)." + ) from exc + + concrete_executable = self.config.executable + concrete_build_command = self.config.build_command + concrete_cwd = self.config.cwd + self.config.executable = ( + concrete_executable if concrete_executable is not None else material.executable + ) + self.config.build_command = ( + concrete_build_command if concrete_build_command is not None else material.build_command + ) + self.config.cwd = ( + concrete_cwd + if concrete_cwd is not None + else (str(material.cwd) if material.cwd is not None else None) + ) + self.config.extra_env = {**material.env, **self.config.extra_env} @rpc def build(self) -> None: @@ -220,6 +272,8 @@ def start(self) -> None: topics = self._collect_topics() + if self.config.executable is None: + raise ValueError(f"[{self._module_label}] No executable configured") cmd = [self.config.executable] for name, topic_str in topics.items(): cmd.extend([f"--{name}", topic_str]) @@ -398,6 +452,8 @@ def _read_log_stream( stream.close() def _maybe_build(self) -> None: + if self.config.executable is None: + raise ValueError(f"[{self._module_label}] No executable configured") exe = Path(self.config.executable) if self.config.build_command is None: diff --git a/dimos/core/runtime_environment.py b/dimos/core/runtime_environment.py new file mode 100644 index 0000000000..b338438871 --- /dev/null +++ b/dimos/core/runtime_environment.py @@ -0,0 +1,119 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from dataclasses import dataclass, field +import os +from pathlib import Path +import sys + + +@dataclass(frozen=True) +class PythonLaunchMaterial: + python_executable: Path + env: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class NativeLaunchMaterial: + executable: str + build_command: str | None = None + cwd: Path | None = None + env: dict[str, str] = field(default_factory=dict) + + +class RuntimeEnvironment: + name: str + + def resolve_python(self) -> PythonLaunchMaterial: + raise RuntimeError( + f"Runtime environment '{self.name}' does not provide Python launch material" + ) + + def resolve_native(self) -> NativeLaunchMaterial: + raise RuntimeError( + f"Runtime environment '{self.name}' does not provide native launch material" + ) + + +@dataclass(frozen=True) +class CurrentProcessRuntimeEnvironment(RuntimeEnvironment): + name: str = "current" + + def resolve_python(self) -> PythonLaunchMaterial: + return PythonLaunchMaterial(python_executable=Path(sys.executable), env=dict(os.environ)) + + +@dataclass(frozen=True) +class PythonVenvRuntimeEnvironment(RuntimeEnvironment): + name: str + python_executable: Path + env: dict[str, str] = field(default_factory=dict) + + def resolve_python(self) -> PythonLaunchMaterial: + return PythonLaunchMaterial(python_executable=self.python_executable, env=dict(self.env)) + + +@dataclass(frozen=True) +class NativeRuntimeEnvironment(RuntimeEnvironment): + name: str + executable: str + build_command: str | None = None + cwd: Path | None = None + env: dict[str, str] = field(default_factory=dict) + + def resolve_native(self) -> NativeLaunchMaterial: + return NativeLaunchMaterial( + executable=self.executable, + build_command=self.build_command, + cwd=self.cwd, + env=dict(self.env), + ) + + +@dataclass(frozen=True) +class NixNativeRuntimeEnvironment(NativeRuntimeEnvironment): + """Named native runtime backed by externally-produced Nix launch material. + + This is intentionally a thin typed model over native launch material. DimOS + does not evaluate Nix expressions here; callers register the resolved + executable/build/cwd/env material. + """ + + +@dataclass(frozen=True) +class RuntimeEnvironmentRegistry: + environments: dict[str, RuntimeEnvironment] = field(default_factory=dict) + + @classmethod + def with_current_process(cls) -> RuntimeEnvironmentRegistry: + current = CurrentProcessRuntimeEnvironment() + return cls(environments={current.name: current}) + + def register(self, environment: RuntimeEnvironment) -> RuntimeEnvironmentRegistry: + return RuntimeEnvironmentRegistry( + environments={**self.environments, environment.name: environment} + ) + + def merge(self, other: RuntimeEnvironmentRegistry) -> RuntimeEnvironmentRegistry: + return RuntimeEnvironmentRegistry(environments={**self.environments, **other.environments}) + + def resolve(self, name: str) -> RuntimeEnvironment: + try: + return self.environments[name] + except KeyError as exc: + known = ", ".join(sorted(self.environments)) or "" + raise KeyError( + f"Unknown runtime environment '{name}'. Known environments: {known}" + ) from exc diff --git a/dimos/core/test_native_module.py b/dimos/core/test_native_module.py index c7efb799b6..61e6005f68 100644 --- a/dimos/core/test_native_module.py +++ b/dimos/core/test_native_module.py @@ -31,8 +31,14 @@ from dimos.core.coordination.blueprints import autoconnect from dimos.core.coordination.module_coordinator import ModuleCoordinator from dimos.core.core import rpc +from dimos.core.global_config import GlobalConfig from dimos.core.module import Module from dimos.core.native_module import LogFormat, NativeModule, NativeModuleConfig +from dimos.core.runtime_environment import ( + NixNativeRuntimeEnvironment, + PythonVenvRuntimeEnvironment, + RuntimeEnvironmentRegistry, +) from dimos.core.stream import In, Out from dimos.core.transport import LCMTransport from dimos.msgs.geometry_msgs.Twist import Twist @@ -76,6 +82,42 @@ class StubNativeModule(NativeModule): cmd_vel: In[Twist] +class RuntimeOnlyNativeConfig(NativeModuleConfig): + runtime_environment: str | None = "native-env" + + +class RuntimeOnlyNativeModule(NativeModule): + config: RuntimeOnlyNativeConfig + + +class RuntimeBlueprintArgNativeConfig(NativeModuleConfig): + pass + + +class RuntimeBlueprintArgNativeModule(NativeModule): + config: RuntimeBlueprintArgNativeConfig + + +class RuntimeMixedNativeConfig(NativeModuleConfig): + runtime_environment: str | None = "native-env" + build_command: str | None = "config build" + cwd: str | None = "config-cwd" + output_file: str | None = None + + +class RuntimeMixedNativeModule(NativeModule): + config: RuntimeMixedNativeConfig + + +class RuntimeSubclassExecutableConfig(NativeModuleConfig): + runtime_environment: str | None = "native-env" + executable: str | None = _ECHO + + +class RuntimeSubclassExecutableModule(NativeModule): + config: RuntimeSubclassExecutableConfig + + class StubConsumer(Module): pointcloud: In[PointCloud2] imu: In[Imu] @@ -149,6 +191,151 @@ def test_manual(dimos_cluster: ModuleCoordinator, args_file: str) -> None: } +def _native_registry(tmp_path: Path) -> RuntimeEnvironmentRegistry: + return RuntimeEnvironmentRegistry.with_current_process().register( + NixNativeRuntimeEnvironment( + name="native-env", + executable=_ECHO, + build_command="env build", + cwd=tmp_path, + env={"ENV_VALUE": "runtime", "OVERLAY": "runtime"}, + ) + ) + + +def test_native_runtime_env_only_resolves_material(tmp_path: Path) -> None: + module = RuntimeOnlyNativeModule(runtime_environment_registry=_native_registry(tmp_path)) + try: + assert module.config.executable == str(tmp_path / _ECHO) + assert module.config.build_command == "env build" + assert module.config.cwd == str(tmp_path) + assert module.config.extra_env == {"ENV_VALUE": "runtime", "OVERLAY": "runtime"} + finally: + module.stop() + + +def test_native_runtime_mixed_precedence_and_env_overlay(tmp_path: Path) -> None: + module = RuntimeMixedNativeModule( + runtime_environment_registry=_native_registry(tmp_path), + executable=_ECHO, + extra_env={"OVERLAY": "config", "CONFIG_ONLY": "1"}, + ) + + try: + assert module.config.executable == _ECHO + assert module.config.build_command == "config build" + assert module.config.cwd == str(Path(__file__).resolve().parent / "config-cwd") + assert module.config.extra_env == { + "ENV_VALUE": "runtime", + "OVERLAY": "config", + "CONFIG_ONLY": "1", + } + finally: + module.stop() + + +def test_native_subclass_default_executable_overrides_runtime(tmp_path: Path) -> None: + module = RuntimeSubclassExecutableModule( + runtime_environment_registry=_native_registry(tmp_path) + ) + try: + assert module.config.executable == _ECHO + finally: + module.stop() + + +def test_native_runtime_missing_name_raises_clear_error() -> None: + with pytest.raises(RuntimeError, match="missing-env.*native capability"): + RuntimeOnlyNativeModule( + runtime_environment="missing-env", + runtime_environment_registry=RuntimeEnvironmentRegistry.with_current_process(), + ) + + +def test_native_runtime_wrong_capability_raises_clear_error() -> None: + registry = RuntimeEnvironmentRegistry.with_current_process().register( + PythonVenvRuntimeEnvironment(name="native-env", python_executable=Path("python")) + ) + + with pytest.raises(RuntimeError, match="native-env.*native capability"): + RuntimeOnlyNativeModule(runtime_environment_registry=registry) + + +def test_native_no_executable_without_runtime_raises_clear_error() -> None: + with pytest.raises(ValueError, match="requires an executable"): + RuntimeOnlyNativeModule(runtime_environment=None) + + +def test_native_runtime_registry_does_not_leak_to_cli_or_stdin_config(tmp_path: Path) -> None: + module = RuntimeMixedNativeModule( + runtime_environment_registry=_native_registry(tmp_path), + executable=_ECHO, + output_file="out.json", + ) + + try: + assert "runtime_environment_registry" not in module.config.to_cli_args() + assert "runtime_environment_registry" not in module.config.to_config_dict() + assert module.config.to_config_dict() == {"output_file": "out.json"} + finally: + module.stop() + + +def test_coordinator_injects_native_runtime_registry_from_blueprint( + args_file: str, tmp_path: Path +) -> None: + blueprint = RuntimeOnlyNativeModule.blueprint( + extra_args=["--output_file", args_file] + ).runtime_environments( + NixNativeRuntimeEnvironment(name="native-env", executable=_ECHO, cwd=tmp_path) + ) + + coordinator = ModuleCoordinator.build(blueprint.global_config(viewer="none")) + try: + module = coordinator.get_instance(RuntimeOnlyNativeModule) + assert module.config.runtime_environment_registry is not None + assert module.config.executable == _ECHO + for _ in range(50): + if Path(args_file).exists(): + break + time.sleep(_WATCHDOG_POLL_INTERVAL) + finally: + coordinator.stop() + + assert read_json_file(args_file)["output_file"] == args_file + + +def test_coordinator_injects_native_runtime_registry_for_blueprint_args_and_restart( + args_file: str, tmp_path: Path +) -> None: + blueprint = RuntimeBlueprintArgNativeModule.blueprint().runtime_environments( + NixNativeRuntimeEnvironment(name="native-env", executable=_ECHO, cwd=tmp_path) + ) + coordinator = ModuleCoordinator(g=GlobalConfig(n_workers=0, viewer="none")) + coordinator.start() + try: + coordinator.load_blueprint( + blueprint, + { + RuntimeBlueprintArgNativeModule.name: { + "runtime_environment": "native-env", + "extra_args": ["--output_file", args_file], + } + }, + ) + module = coordinator.get_instance(RuntimeBlueprintArgNativeModule) + assert module.config.runtime_environment_registry is not None + assert module.config.runtime_environment == "native-env" + assert module.config.executable == _ECHO + + restarted = coordinator.restart_module(RuntimeBlueprintArgNativeModule, reload_source=False) + assert restarted.config.runtime_environment_registry is not None + assert restarted.config.runtime_environment == "native-env" + assert restarted.config.executable == _ECHO + finally: + coordinator.stop() + + def test_autoconnect(args_file: str) -> None: """autoconnect passes correct topic args to the native subprocess.""" blueprint = autoconnect( diff --git a/dimos/core/test_runtime_environment.py b/dimos/core/test_runtime_environment.py new file mode 100644 index 0000000000..3ca160477c --- /dev/null +++ b/dimos/core/test_runtime_environment.py @@ -0,0 +1,88 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from pathlib import Path +import sys + +import pytest + +from dimos.core.runtime_environment import ( + CurrentProcessRuntimeEnvironment, + NativeRuntimeEnvironment, + NixNativeRuntimeEnvironment, + PythonVenvRuntimeEnvironment, + RuntimeEnvironmentRegistry, +) + + +def test_register_and_resolve_environment() -> None: + env = PythonVenvRuntimeEnvironment(name="tools", python_executable=Path("python")) + registry = RuntimeEnvironmentRegistry.with_current_process().register(env) + + assert registry.resolve("tools") is env + + +def test_current_process_python_material() -> None: + material = CurrentProcessRuntimeEnvironment().resolve_python() + + assert material.python_executable == Path(sys.executable) + assert material.env + + +def test_python_venv_material() -> None: + env = PythonVenvRuntimeEnvironment( + name="venv", python_executable=Path("/tmp/venv/bin/python"), env={"A": "B"} + ) + + material = env.resolve_python() + + assert material.python_executable == Path("/tmp/venv/bin/python") + assert material.env == {"A": "B"} + + +def test_nix_native_material() -> None: + env = NixNativeRuntimeEnvironment( + name="native", + executable="/nix/store/bin/tool", + build_command="nix build", + cwd=Path("/tmp"), + env={"X": "Y"}, + ) + + material = env.resolve_native() + + assert material.executable == "/nix/store/bin/tool" + assert material.build_command == "nix build" + assert material.cwd == Path("/tmp") + assert material.env == {"X": "Y"} + + +def test_missing_name_error_lists_known_names() -> None: + registry = RuntimeEnvironmentRegistry.with_current_process() + + with pytest.raises(KeyError, match="Unknown runtime environment 'missing'.*current"): + registry.resolve("missing") + + +def test_unsupported_python_capability_error() -> None: + env = NativeRuntimeEnvironment(name="native", executable="tool") + + with pytest.raises(RuntimeError, match="does not provide Python launch material"): + env.resolve_python() + + +def test_unsupported_native_capability_error() -> None: + env = PythonVenvRuntimeEnvironment(name="venv", python_executable=Path("python")) + + with pytest.raises(RuntimeError, match="does not provide native launch material"): + env.resolve_native() diff --git a/dimos/core/test_venv_module_demo.py b/dimos/core/test_venv_module_demo.py new file mode 100644 index 0000000000..9eb36080b0 --- /dev/null +++ b/dimos/core/test_venv_module_demo.py @@ -0,0 +1,74 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from pathlib import Path +import sys +from types import MappingProxyType + +import pytest + +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.core.runtime_environment import PythonVenvRuntimeEnvironment + +_BUILD_WITHOUT_RERUN = MappingProxyType({"g": {"viewer": "none", "n_workers": 1}}) +_REPO_ROOT = Path(__file__).resolve().parents[2] +_DEMO_SRC = _REPO_ROOT / "packages" / "dimos-demo-worker-module" / "src" + + +@pytest.fixture +def demo_import_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.syspath_prepend(str(_DEMO_SRC)) + sys.modules.pop("dimos_demo_worker_module", None) + sys.modules.pop("dimos_demo_worker_module.blueprint", None) + sys.modules.pop("dimos_demo_worker_module.runtime_dependency", None) + + +def test_demo_blueprint_imports_and_builds_without_worker_dependency_in_coordinator( + demo_import_path: None, +) -> None: + from dimos_demo_worker_module import VenvDemoConsumer, VenvDemoPublisher, venv_demo_blueprint + + runtime = PythonVenvRuntimeEnvironment( + name="dimos-demo-venv", + python_executable=Path(sys.executable), + env={"PYTHONPATH": str(_DEMO_SRC)}, + ) + coordinator = ModuleCoordinator.build(venv_demo_blueprint(runtime), _BUILD_WITHOUT_RERUN.copy()) + try: + assert coordinator.get_instance(VenvDemoPublisher) is not None + assert coordinator.get_instance(VenvDemoConsumer) is not None + assert coordinator._module_manager_keys[VenvDemoPublisher] == "python:dimos-demo-venv" + assert coordinator._module_manager_keys[VenvDemoConsumer] == "python" + finally: + coordinator.stop() + + +def test_demo_runtime_runs_package_helper_in_venv_worker( + demo_import_path: None, +) -> None: + from dimos_demo_worker_module import VENV_DEMO_ENV_NAME, VenvDemoConsumer, venv_demo_blueprint + + runtime = PythonVenvRuntimeEnvironment( + name=VENV_DEMO_ENV_NAME, + python_executable=Path(sys.executable), + env={"PYTHONPATH": str(_DEMO_SRC)}, + ) + coordinator = ModuleCoordinator.build(venv_demo_blueprint(runtime), _BUILD_WITHOUT_RERUN.copy()) + try: + consumer = coordinator.get_instance(VenvDemoConsumer) + assert consumer is not None + assert consumer.consume_message("ok") == "worker-runtime::ok" + finally: + coordinator.stop() diff --git a/docs/usage/README.md b/docs/usage/README.md index 49f38b6f0b..3f90aa7bad 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -7,6 +7,7 @@ This page explains general concepts. - [Modules](/docs/usage/modules.md): The primary units of deployment in DimOS, modules run in parallel and are python classes. - [Streams](/docs/usage/sensor_streams/README.md): How modules communicate, a Pub / Sub system. - [Blueprints](/docs/usage/blueprints.md): a way to group modules together and define their connections to each other. +- [Runtime environments](/docs/usage/runtime_environments.md): run selected Python modules in named venv workers, or resolve native executable settings from named environments. - [VGN MuJoCo Grasp Demo](/docs/usage/vgn_mujoco_grasp_demo.md): opt-in xArm7 TSDF reconstruction and grasp candidate visualization demo. - [RPC](/docs/usage/blueprints.md#calling-the-methods-of-other-modules): how one module can call a method on another module (arguments get serialized to JSON-like binary data). - [Skills](/docs/usage/blueprints.md#defining-skills): An RPC function, except it can be called by an AI agent (a tool for an AI). diff --git a/docs/usage/native_modules.md b/docs/usage/native_modules.md index bb31cdd456..7e4535d128 100644 --- a/docs/usage/native_modules.md +++ b/docs/usage/native_modules.md @@ -79,11 +79,12 @@ When `stop()` is called, the process receives SIGTERM. If it doesn't exit within | Field | Type | Default | Description | |--------------------|------------------|---------------|-------------------------------------------------------------| -| `executable` | `str` | *(required)* | Path to the native binary (relative to `cwd` if set) | +| `executable` | `str \| None` | `None` | Path to the native binary (relative to `cwd` if set). Required unless a runtime environment supplies one | | `build_command` | `str \| None` | `None` | Shell command to run if executable is missing (auto-build) | | `cwd` | `str \| None` | `None` | Working directory for build and runtime. Relative paths are resolved against the Python file defining the module | | `extra_args` | `list[str]` | `[]` | Additional CLI arguments appended after auto-generated ones | | `extra_env` | `dict[str, str]` | `{}` | Extra environment variables for the subprocess | +| `runtime_environment` | `str \| None` | `None` | Optional named runtime environment that supplies native launch defaults | | `shutdown_timeout` | `float` | `10.0` | Seconds to wait for SIGTERM before SIGKILL | | `log_format` | `LogFormat` | `TEXT` | How to parse subprocess output (`TEXT` or `JSON`) | | `cli_exclude` | `frozenset[str]` | `frozenset()` | Config fields to skip when generating CLI args | @@ -111,6 +112,37 @@ class MyConfig(NativeModuleConfig): - Booleans are lowercased (`true`/`false`). - Lists are comma-joined. +### Runtime environment defaults + +Native modules may get `executable`, `build_command`, `cwd`, and environment defaults from a named runtime environment. Register the environment on the blueprint with `.runtime_environments(...)`, then set `runtime_environment` in the native config. + +```python skip +from pathlib import Path + +from dimos.core.runtime_environment import NixNativeRuntimeEnvironment + +native_env = NixNativeRuntimeEnvironment( + name="lidar-native", + executable="result/bin/my_lidar", + build_command="nix build .#my_lidar", + cwd=Path("cpp"), +) + +class MyConfig(NativeModuleConfig): + runtime_environment: str | None = "lidar-native" + host_ip: str = "192.168.1.5" + +blueprint = autoconnect(MyLidar.blueprint()).runtime_environments(native_env) +``` + +Precedence: + +1. The runtime environment supplies defaults. +2. Non-`None` config values override `executable`, `build_command`, and `cwd`. +3. `extra_env` overlays the runtime environment's environment. + +Configs that set `executable`, `build_command`, `cwd`, and `extra_env` directly still work unchanged. + ### Excluding fields If a config field shouldn't be a CLI arg, add it to `cli_exclude`: diff --git a/docs/usage/runtime_environments.md b/docs/usage/runtime_environments.md new file mode 100644 index 0000000000..35590e7631 --- /dev/null +++ b/docs/usage/runtime_environments.md @@ -0,0 +1,120 @@ +# Runtime Environments + +Runtime environments let a blueprint choose how selected processes launch without making that choice part of the module class. + +Use them when one module needs a different Python environment, or when a native module should get its executable settings from a named environment. + +## Python venv workers + +A Python module can run in a named Python environment while other modules stay in the default worker pool. + +```python skip +from pathlib import Path + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.runtime_environment import PythonVenvRuntimeEnvironment + +runtime = PythonVenvRuntimeEnvironment( + name="sensors", + python_executable=Path("/opt/dimos-sensors/.venv/bin/python"), +) + +blueprint = ( + autoconnect(CameraModule.blueprint(), ConsumerModule.blueprint()) + .runtime_environments(runtime) + .runtime_placements({CameraModule: "sensors"}) +) +``` + +`CameraModule` runs in a worker launched by `/opt/dimos-sensors/.venv/bin/python`. `ConsumerModule` remains in the default Python worker pool. Modules in the same named environment share that environment's worker pool. Modules in different named environments never share a worker process. + +The venv worker uses the same DimOS worker protocol as the default worker. Lifecycle calls, streams, module refs, and RPCs work the same way. + +## Import-safe venv modules + +The coordinator imports module classes before it launches workers. A venv-deployable module file must therefore be importable in the coordinator environment. + +Follow these rules: + +- Keep the module class at a top-level import path that exists in both the coordinator and worker environments. +- Do not import worker-only dependencies at module import time. +- Import worker-only dependencies inside `start()`, stream callbacks, RPC methods, or helper functions called by those methods. +- Avoid class-level annotations that require worker-only packages. The coordinator resolves annotations while it builds the blueprint. +- Install the module package and its worker-only dependencies into the named worker environment. + +Minimal pattern: + +```python skip +from dimos.core.core import rpc +from dimos.core.module import Module + + +class VenvOnlyModel(Module): + @rpc + def run_model(self, text: str) -> str: + from worker_only_package import predict + + return predict(text) +``` + +The demo package at `packages/dimos-demo-worker-module/` follows this pattern. Its publisher imports a runtime helper only inside a worker-side RPC. The test `dimos/core/test_venv_module_demo.py` proves the coordinator can import and build the blueprint, while the placed worker runs the module through the named runtime environment. + +## Packaging convention + +Place venv-deployable modules in their own Python package when they have a dependency closure that should not be installed in the coordinator environment. + +Recommended layout: + +```text +packages/my-venv-module/ +├── pyproject.toml +└── src/my_venv_module/ + ├── __init__.py + └── blueprint.py +``` + +The package's `pyproject.toml` should declare the worker dependencies needed by that module. The coordinator does not need those dependencies if the module imports them lazily. + +Phase 1 packages may depend on the root `dimos` package. A future split can replace that with a smaller worker runtime package. + +## Native module runtime environments + +Native modules can reference a named native runtime environment instead of repeating executable/build settings in every config. + +```python skip +from pathlib import Path + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.native_module import NativeModuleConfig +from dimos.core.runtime_environment import NixNativeRuntimeEnvironment + +native_env = NixNativeRuntimeEnvironment( + name="mid360-native", + executable="result/bin/mid360_native", + build_command="nix build .#mid360_native", + cwd=Path("cpp"), + env={"RUST_BACKTRACE": "1"}, +) + +class Mid360Config(NativeModuleConfig): + runtime_environment: str | None = "mid360-native" + host_ip: str = "192.168.1.5" + +blueprint = autoconnect(Mid360.blueprint()).runtime_environments(native_env) +``` + +Precedence is deterministic: + +1. The runtime environment provides defaults for `executable`, `build_command`, `cwd`, and environment variables. +2. Non-`None` config values, including subclass defaults, override `executable`, `build_command`, and `cwd`. +3. `extra_env` overlays the runtime environment's environment variables. +4. Module-specific config fields still become CLI args as before. + +Legacy native configs without `runtime_environment` continue to work. + +## Phase 1 limits + +- Venv workers run on the same machine as the coordinator. +- The venv must contain compatible DimOS worker runtime code. In phase 1, this usually means the venv can import the same source checkout or an equivalent installed `dimos` package. +- DimOS does not create, install, or synchronize venvs for you yet. Prepare the environment before running the blueprint. +- There is no remote deployment agent yet. Runtime environments only select local process launch material. diff --git a/openspec/changes/venv-deployable-modules/.openspec.yaml b/openspec/changes/archive/2026-06-26-venv-deployable-modules/.openspec.yaml similarity index 100% rename from openspec/changes/venv-deployable-modules/.openspec.yaml rename to openspec/changes/archive/2026-06-26-venv-deployable-modules/.openspec.yaml diff --git a/openspec/changes/venv-deployable-modules/design.md b/openspec/changes/archive/2026-06-26-venv-deployable-modules/design.md similarity index 100% rename from openspec/changes/venv-deployable-modules/design.md rename to openspec/changes/archive/2026-06-26-venv-deployable-modules/design.md diff --git a/openspec/changes/venv-deployable-modules/proposal.md b/openspec/changes/archive/2026-06-26-venv-deployable-modules/proposal.md similarity index 100% rename from openspec/changes/venv-deployable-modules/proposal.md rename to openspec/changes/archive/2026-06-26-venv-deployable-modules/proposal.md diff --git a/openspec/changes/venv-deployable-modules/specs/runtime-environment-registry/spec.md b/openspec/changes/archive/2026-06-26-venv-deployable-modules/specs/runtime-environment-registry/spec.md similarity index 100% rename from openspec/changes/venv-deployable-modules/specs/runtime-environment-registry/spec.md rename to openspec/changes/archive/2026-06-26-venv-deployable-modules/specs/runtime-environment-registry/spec.md diff --git a/openspec/changes/venv-deployable-modules/specs/venv-module-packaging/spec.md b/openspec/changes/archive/2026-06-26-venv-deployable-modules/specs/venv-module-packaging/spec.md similarity index 80% rename from openspec/changes/venv-deployable-modules/specs/venv-module-packaging/spec.md rename to openspec/changes/archive/2026-06-26-venv-deployable-modules/specs/venv-module-packaging/spec.md index 27bd4440c2..806678c59d 100644 --- a/openspec/changes/venv-deployable-modules/specs/venv-module-packaging/spec.md +++ b/openspec/changes/archive/2026-06-26-venv-deployable-modules/specs/venv-module-packaging/spec.md @@ -33,13 +33,13 @@ The system SHALL allow phase-1 venv Module packages to depend on the current roo - **WHEN** a smaller DimOS worker runtime package becomes available - **THEN** venv Module packages can depend on that runtime package instead of the full root `dimos` package -### Requirement: Demo proves dependency isolation with a lightweight package -The system SHALL include a demo package and blueprint proving that a Module-specific dependency can exist only in the venv worker environment while the coordinator still imports, builds, and wires the blueprint. +### Requirement: Demo proves venv worker placement with a lightweight package +The system SHALL include a demo package and blueprint proving that a Module can be declared in one import-safe package and run in a named venv worker environment while the coordinator imports, builds, and wires the blueprint. -#### Scenario: Coordinator lacks demo runtime dependency -- **WHEN** the coordinator environment does not have the demo package's worker-only dependency installed -- **THEN** the coordinator can still import the demo Module class and build the demo blueprint +#### Scenario: Coordinator imports demo package +- **WHEN** the coordinator imports the demo Module class and builds the demo blueprint +- **THEN** the import and build succeed without running the demo's runtime helper at module import time -#### Scenario: Venv worker uses demo runtime dependency +#### Scenario: Venv worker uses demo runtime helper - **WHEN** the demo blueprint runs with the demo Module placed into its named Python runtime environment -- **THEN** the Module uses the worker-only dependency inside the venv and publishes or responds through normal DimOS Module behavior +- **THEN** the Module uses its package-local runtime helper inside the venv and publishes or responds through normal DimOS Module behavior diff --git a/openspec/changes/venv-deployable-modules/specs/venv-module-placement/spec.md b/openspec/changes/archive/2026-06-26-venv-deployable-modules/specs/venv-module-placement/spec.md similarity index 100% rename from openspec/changes/venv-deployable-modules/specs/venv-module-placement/spec.md rename to openspec/changes/archive/2026-06-26-venv-deployable-modules/specs/venv-module-placement/spec.md diff --git a/openspec/changes/archive/2026-06-26-venv-deployable-modules/tasks.md b/openspec/changes/archive/2026-06-26-venv-deployable-modules/tasks.md new file mode 100644 index 0000000000..0db6fb4d98 --- /dev/null +++ b/openspec/changes/archive/2026-06-26-venv-deployable-modules/tasks.md @@ -0,0 +1,52 @@ +## 1. Worker Launch Abstraction + +- [x] 1.1 Extract the current forkserver process/Pipe operations into a worker process handle abstraction without changing default worker behavior. +- [x] 1.2 Add a worker launcher abstraction with a forkserver launcher implementation that preserves current `PythonWorker` deployment tests. +- [x] 1.3 Update worker manager code to use launcher/process-handle interfaces while keeping existing scheduling, capacity, dedicated-worker, and deploy_parallel behavior. +- [x] 1.4 Add unit tests proving the default forkserver worker path still deploys, starts, calls RPCs, and shuts down Modules as before. + +## 2. Venv Worker Control Channel + +- [x] 2.1 Add a worker entrypoint that can be launched with an arbitrary Python executable and connect back to the coordinator using `multiprocessing.connection.Client`. +- [x] 2.2 Add a coordinator-side `multiprocessing.connection.Listener` setup for venv worker launch and connection acceptance. +- [x] 2.3 Implement a venv worker process handle that sends and receives existing worker request/response objects over the multiprocessing connection channel. +- [x] 2.4 Ensure worker stdout/stderr are handled separately from the control channel so logs cannot corrupt worker messages. +- [x] 2.5 Add failure tests for missing Python executable, worker connection timeout, incompatible worker import, and worker startup error propagation. + +## 3. Runtime Environment Registry + +- [x] 3.1 Define typed runtime environment models for current process, Python venv, and Nix-backed native executable resolution. +- [x] 3.2 Add a Python-first runtime environment registry that resolves named environments and reports clear errors for unknown names or unsupported capabilities. +- [x] 3.3 Wire runtime environment registry into blueprint/global runtime configuration without requiring YAML or TOML files. +- [x] 3.4 Add tests for registering environments, resolving Python interpreter material, resolving native executable material, and missing-name diagnostics. + +## 4. Blueprint Venv Placement + +- [x] 4.1 Add a blueprint-level placement API for assigning Module classes to named Python runtime environments. +- [x] 4.2 Route placed Modules to named venv worker pools while unplaced Modules continue using the default worker pool. +- [x] 4.3 Preserve same-env worker sharing and prevent cross-env Module mixing within one worker process. +- [x] 4.4 Add integration tests with two Modules in one named venv pool and two Modules in distinct named venv pools. +- [x] 4.5 Verify stream wiring, Module refs, and RPC calls work for Modules placed in venv worker pools. + +## 5. Native Module Runtime Environment Opt-In + +- [x] 5.1 Extend `NativeModuleConfig` with optional runtime environment reference while preserving existing executable/build_command/cwd/extra_env fields. +- [x] 5.2 Define and test deterministic precedence when a native runtime environment and legacy native config fields are both provided. +- [x] 5.3 Update one Nix-backed native module test fixture or fake native module to resolve executable/build/env through a named runtime environment. +- [x] 5.4 Confirm existing NativeModule tests and existing Nix-backed native module configs continue to work unchanged. + +## 6. Venv Module Packaging Convention and Demo + +- [x] 6.1 Add a small separately packaged demo venv Module with its own `pyproject.toml` and package-local runtime helper. +- [x] 6.2 Make the demo Module import-safe by avoiding runtime helper imports at module import time. +- [x] 6.3 Add a demo blueprint that places the demo publisher Module into a named Python venv runtime environment and keeps a consumer Module in the default environment. +- [x] 6.4 Add demo verification showing the coordinator can import/build the blueprint. +- [x] 6.5 Add runtime demo verification showing the venv worker runs the package helper and communicates through normal DimOS streams or RPCs. + +## 7. Documentation and Validation + +- [x] 7.1 Document import-safe module file rules and the separately packaged venv Module convention. +- [x] 7.2 Document runtime environment registry usage for Python venv workers and Nix-backed native modules. +- [x] 7.3 Document phase-1 limitations: same-machine only, compatible DimOS/source versions required, and no remote deployment agent yet. +- [x] 7.4 Run focused worker, blueprint, native module, and demo tests. +- [x] 7.5 Run broader relevant test suite or document any skipped slow/hardware-dependent tests. diff --git a/openspec/changes/venv-deployable-modules/tasks.md b/openspec/changes/venv-deployable-modules/tasks.md deleted file mode 100644 index bd40eca181..0000000000 --- a/openspec/changes/venv-deployable-modules/tasks.md +++ /dev/null @@ -1,52 +0,0 @@ -## 1. Worker Launch Abstraction - -- [ ] 1.1 Extract the current forkserver process/Pipe operations into a worker process handle abstraction without changing default worker behavior. -- [ ] 1.2 Add a worker launcher abstraction with a forkserver launcher implementation that preserves current `PythonWorker` deployment tests. -- [ ] 1.3 Update worker manager code to use launcher/process-handle interfaces while keeping existing scheduling, capacity, dedicated-worker, and deploy_parallel behavior. -- [ ] 1.4 Add unit tests proving the default forkserver worker path still deploys, starts, calls RPCs, and shuts down Modules as before. - -## 2. Venv Worker Control Channel - -- [ ] 2.1 Add a worker entrypoint that can be launched with an arbitrary Python executable and connect back to the coordinator using `multiprocessing.connection.Client`. -- [ ] 2.2 Add a coordinator-side `multiprocessing.connection.Listener` setup for venv worker launch and connection acceptance. -- [ ] 2.3 Implement a venv worker process handle that sends and receives existing worker request/response objects over the multiprocessing connection channel. -- [ ] 2.4 Ensure worker stdout/stderr are handled separately from the control channel so logs cannot corrupt worker messages. -- [ ] 2.5 Add failure tests for missing Python executable, worker connection timeout, incompatible worker import, and worker startup error propagation. - -## 3. Runtime Environment Registry - -- [ ] 3.1 Define typed runtime environment models for current process, Python venv, and Nix-backed native executable resolution. -- [ ] 3.2 Add a Python-first runtime environment registry that resolves named environments and reports clear errors for unknown names or unsupported capabilities. -- [ ] 3.3 Wire runtime environment registry into blueprint/global runtime configuration without requiring YAML or TOML files. -- [ ] 3.4 Add tests for registering environments, resolving Python interpreter material, resolving native executable material, and missing-name diagnostics. - -## 4. Blueprint Venv Placement - -- [ ] 4.1 Add a blueprint-level placement API for assigning Module classes to named Python runtime environments. -- [ ] 4.2 Route placed Modules to named venv worker pools while unplaced Modules continue using the default worker pool. -- [ ] 4.3 Preserve same-env worker sharing and prevent cross-env Module mixing within one worker process. -- [ ] 4.4 Add integration tests with two Modules in one named venv pool and two Modules in distinct named venv pools. -- [ ] 4.5 Verify stream wiring, Module refs, and RPC calls work for Modules placed in venv worker pools. - -## 5. Native Module Runtime Environment Opt-In - -- [ ] 5.1 Extend `NativeModuleConfig` with optional runtime environment reference while preserving existing executable/build_command/cwd/extra_env fields. -- [ ] 5.2 Define and test deterministic precedence when a native runtime environment and legacy native config fields are both provided. -- [ ] 5.3 Update one Nix-backed native module test fixture or fake native module to resolve executable/build/env through a named runtime environment. -- [ ] 5.4 Confirm existing NativeModule tests and existing Nix-backed native module configs continue to work unchanged. - -## 6. Venv Module Packaging Convention and Demo - -- [ ] 6.1 Add a small separately packaged demo venv Module with its own `pyproject.toml` and a lightweight worker-only dependency not required by the coordinator environment. -- [ ] 6.2 Make the demo Module import-safe by avoiding worker-only dependency imports at module import time. -- [ ] 6.3 Add a demo blueprint that places the demo publisher Module into a named Python venv runtime environment and keeps a consumer Module in the default environment. -- [ ] 6.4 Add demo verification showing the coordinator can import/build the blueprint without the worker-only dependency installed. -- [ ] 6.5 Add runtime demo verification showing the venv worker imports the worker-only dependency and communicates through normal DimOS streams or RPCs. - -## 7. Documentation and Validation - -- [ ] 7.1 Document import-safe module file rules and the separately packaged venv Module convention. -- [ ] 7.2 Document runtime environment registry usage for Python venv workers and Nix-backed native modules. -- [ ] 7.3 Document phase-1 limitations: same-machine only, compatible DimOS/source versions required, and no remote deployment agent yet. -- [ ] 7.4 Run focused worker, blueprint, native module, and demo tests. -- [ ] 7.5 Run broader relevant test suite or document any skipped slow/hardware-dependent tests. diff --git a/openspec/specs/runtime-environment-registry/spec.md b/openspec/specs/runtime-environment-registry/spec.md new file mode 100644 index 0000000000..61b1ec5b76 --- /dev/null +++ b/openspec/specs/runtime-environment-registry/spec.md @@ -0,0 +1,49 @@ +## Purpose + +Define named runtime environments that DimOS blueprints and modules can resolve through typed Python configuration for Python worker launch and native executable launch material. + +## Requirements + +### Requirement: Runtime environments are named and Python-configurable +The system SHALL provide a Python API for registering named runtime environments that DimOS-managed processes can reference at blueprint build and module runtime. + +#### Scenario: Register runtime environments through Python API +- **WHEN** a blueprint or runtime configuration registers named runtime environments using typed Python objects +- **THEN** the coordinator resolves those environment names without requiring a YAML or TOML configuration file + +#### Scenario: Missing runtime environment name fails clearly +- **WHEN** a Module placement or NativeModule config references a runtime environment name that is not registered +- **THEN** blueprint build or module build fails with an error identifying the missing runtime environment name + +### Requirement: Runtime environments expose capability-specific resolution +The system SHALL let runtime environment consumers request only the runtime capability they need, such as Python interpreter resolution for worker launch or native executable resolution for `NativeModule` launch. + +#### Scenario: Python venv worker requests Python launch material +- **WHEN** a venv worker placement references a Python venv runtime environment +- **THEN** the worker launcher receives a Python executable path and environment variables needed to start the worker process + +#### Scenario: Native module requests native executable material +- **WHEN** a NativeModule references a Nix-backed runtime environment +- **THEN** the NativeModule receives executable, cwd, environment variables, and optional preparation/build material without transferring native process lifecycle ownership to the registry + +### Requirement: Runtime environments support current and Nix-backed execution material +The system SHALL support at least a current-process environment backend and a Nix-backed environment backend sufficient to model existing native module build/executable configuration. + +#### Scenario: Current environment backend is used +- **WHEN** a Module or process uses the current runtime environment +- **THEN** DimOS launches it with the coordinator's current Python/runtime environment semantics + +#### Scenario: Nix-backed environment resolves existing native configuration +- **WHEN** a Nix-backed runtime environment names a flake/package/executable equivalent to an existing native module `build_command` and `executable` +- **THEN** NativeModule can launch the same native process behavior through named environment resolution + +### Requirement: NativeModule legacy configuration remains supported +The system SHALL keep existing `NativeModuleConfig` executable, build command, cwd, and extra environment fields usable while adding named runtime environment resolution. + +#### Scenario: Legacy native configuration continues to work +- **WHEN** a NativeModule is configured only with existing executable/build/cwd/extra_env fields +- **THEN** it builds and starts using the same behavior as before runtime environment registry support + +#### Scenario: Runtime environment and legacy overrides are combined deterministically +- **WHEN** a NativeModule references a runtime environment and also supplies supported legacy override fields +- **THEN** DimOS applies a documented precedence order and launches with the resulting executable, cwd, env, and build behavior diff --git a/openspec/specs/venv-module-packaging/spec.md b/openspec/specs/venv-module-packaging/spec.md new file mode 100644 index 0000000000..dbc3b82884 --- /dev/null +++ b/openspec/specs/venv-module-packaging/spec.md @@ -0,0 +1,49 @@ +## Purpose + +Define the import-safety and packaging convention for Python Modules whose worker runtime dependencies should live outside the coordinator environment. + +## Requirements + +### Requirement: Venv-deployable Modules are import-safe in coordinator environments +The system SHALL define venv-deployable Python Modules as import-safe Module classes whose defining files can be imported by the coordinator environment without importing worker-only optional dependencies at module import time. + +#### Scenario: Coordinator imports Module class without worker-only dependency +- **WHEN** the coordinator imports a venv-deployable Module class for blueprint wiring +- **THEN** the import succeeds even if the coordinator environment does not have the Module's worker-only runtime dependency installed + +#### Scenario: Worker-only dependency is imported at runtime +- **WHEN** the Module starts inside its assigned venv worker environment +- **THEN** it may import and use dependencies declared by its venv module package + +### Requirement: Venv Module packages declare dependency closure with pyproject +The system SHALL support separately packaged Python distributions for venv-deployable Modules where each package's `pyproject.toml` declares the dependency closure required by those Modules. + +#### Scenario: Venv module package installs into isolated venv +- **WHEN** a named Python runtime environment is prepared for a venv Module package +- **THEN** that environment installs the package according to its own `pyproject.toml` dependency declaration + +#### Scenario: Package dependency conflicts are isolated per venv package +- **WHEN** two venv Module packages require incompatible Python dependencies +- **THEN** each package can be installed in a separate named Python runtime environment without forcing those dependencies to resolve together in the coordinator environment + +### Requirement: Venv Module packages may depend on current dimos in phase 1 +The system SHALL allow phase-1 venv Module packages to depend on the current root `dimos` package while preserving a path to depend on a smaller worker runtime package later. + +#### Scenario: Phase-1 package depends on root dimos +- **WHEN** a demo or early venv Module package declares a dependency on the current `dimos` package plus module-specific dependencies +- **THEN** DimOS can launch the package in an isolated venv worker environment without requiring a prior core package split + +#### Scenario: Future package depends on worker runtime subset +- **WHEN** a smaller DimOS worker runtime package becomes available +- **THEN** venv Module packages can depend on that runtime package instead of the full root `dimos` package + +### Requirement: Demo proves venv worker placement with a lightweight package +The system SHALL include a demo package and blueprint proving that a Module can be declared in one import-safe package and run in a named venv worker environment while the coordinator imports, builds, and wires the blueprint. + +#### Scenario: Coordinator imports demo package +- **WHEN** the coordinator imports the demo Module class and builds the demo blueprint +- **THEN** the import and build succeed without running the demo's runtime helper at module import time + +#### Scenario: Venv worker uses demo runtime helper +- **WHEN** the demo blueprint runs with the demo Module placed into its named Python runtime environment +- **THEN** the Module uses its package-local runtime helper inside the venv and publishes or responds through normal DimOS Module behavior diff --git a/openspec/specs/venv-module-placement/spec.md b/openspec/specs/venv-module-placement/spec.md new file mode 100644 index 0000000000..ff153a4408 --- /dev/null +++ b/openspec/specs/venv-module-placement/spec.md @@ -0,0 +1,64 @@ +## Purpose + +Define blueprint-level placement of import-safe Python Modules into named local Python runtime environments while preserving normal DimOS worker protocol behavior. + +## Requirements + +### Requirement: Blueprints place Modules into named Python runtime environments +The system SHALL allow blueprints to place selected import-safe Python Module classes into named Python runtime environments without making the placement an intrinsic Module class property. + +#### Scenario: Blueprint places one Module into a venv environment +- **WHEN** a blueprint places `CameraModule` into named runtime environment `sensors` +- **THEN** the coordinator deploys that Module to a worker process launched from the `sensors` Python environment + +#### Scenario: Same Module can run without venv placement +- **WHEN** the same Module class appears in a blueprint without venv placement +- **THEN** the coordinator deploys it through the default Python worker pool + +### Requirement: Venv workers preserve DimOS worker protocol semantics +The system SHALL preserve normal DimOS worker request/response semantics for Modules deployed into venv worker processes. + +#### Scenario: Venv Module receives lifecycle RPCs +- **WHEN** the coordinator builds, starts, stops, or undeploys a Module placed into a venv worker +- **THEN** the Module receives the same lifecycle calls it would receive in the default Python worker pool + +#### Scenario: Venv Module participates in stream wiring +- **WHEN** a Module placed into a venv worker has typed input or output streams +- **THEN** the coordinator wires those streams using the normal DimOS stream transport mechanism + +#### Scenario: Venv Module participates in Module refs and RPC calls +- **WHEN** another Module calls an RPC or uses a ref exposed by a Module placed into a venv worker +- **THEN** the call uses the normal DimOS proxy behavior and returns or fails according to the existing worker protocol semantics + +### Requirement: Venv workers use local multiprocessing connection control channel +The system SHALL launch venv worker processes as separate Python interpreters and use Python `multiprocessing.connection.Listener/Client` for the same-machine worker control channel. + +#### Scenario: Venv worker connects to coordinator control channel +- **WHEN** the coordinator launches a venv worker process +- **THEN** the worker connects to the coordinator's local multiprocessing connection endpoint before receiving deploy requests + +#### Scenario: Worker logs do not corrupt control messages +- **WHEN** a venv worker or hosted Module writes to stdout or stderr +- **THEN** those logs do not corrupt the worker request/response control channel + +### Requirement: Named venv worker pools isolate Python environments +The system SHALL maintain separate worker pools for distinct named Python runtime environments and SHALL NOT mix Modules assigned to different Python environments in the same worker process. + +#### Scenario: Multiple Modules share one named venv pool +- **WHEN** two Modules are placed into the same named Python runtime environment and capacity allows sharing +- **THEN** they may be hosted by the same venv worker pool according to existing worker capacity rules + +#### Scenario: Modules in different named venvs are isolated +- **WHEN** two Modules are placed into different named Python runtime environments +- **THEN** they run in separate worker pools launched from their respective Python interpreters + +### Requirement: Venv deployment fails during normal build lifecycle on incompatible environments +The system SHALL fail the normal blueprint build/deploy lifecycle when a named venv environment is missing, incompatible, or unable to import required DimOS worker runtime code. + +#### Scenario: Missing venv Python fails build +- **WHEN** a placement references a Python runtime environment whose configured Python executable does not exist +- **THEN** blueprint build fails with an actionable error identifying the runtime environment and missing executable + +#### Scenario: Worker runtime import failure fails build +- **WHEN** a venv worker starts but cannot import compatible DimOS worker runtime modules +- **THEN** deployment fails instead of silently degrading into a partial blueprint diff --git a/packages/dimos-demo-worker-module/pyproject.toml b/packages/dimos-demo-worker-module/pyproject.toml new file mode 100644 index 0000000000..5df8a8d3d0 --- /dev/null +++ b/packages/dimos-demo-worker-module/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "dimos-demo-worker-module" +version = "0.1.0" +description = "Separately packaged DimOS module demo for Python venv placement." +requires-python = ">=3.11" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/packages/dimos-demo-worker-module/src/dimos_demo_worker_module/__init__.py b/packages/dimos-demo-worker-module/src/dimos_demo_worker_module/__init__.py new file mode 100644 index 0000000000..85fc053b08 --- /dev/null +++ b/packages/dimos-demo-worker-module/src/dimos_demo_worker_module/__init__.py @@ -0,0 +1,13 @@ +from dimos_demo_worker_module.blueprint import ( + VENV_DEMO_ENV_NAME, + VenvDemoConsumer, + VenvDemoPublisher, + venv_demo_blueprint, +) + +__all__ = [ + "VENV_DEMO_ENV_NAME", + "VenvDemoConsumer", + "VenvDemoPublisher", + "venv_demo_blueprint", +] diff --git a/packages/dimos-demo-worker-module/src/dimos_demo_worker_module/blueprint.py b/packages/dimos-demo-worker-module/src/dimos_demo_worker_module/blueprint.py new file mode 100644 index 0000000000..04634ca351 --- /dev/null +++ b/packages/dimos-demo-worker-module/src/dimos_demo_worker_module/blueprint.py @@ -0,0 +1,54 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from pathlib import Path +import sys + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.core import rpc +from dimos.core.module import Module +from dimos.core.runtime_environment import PythonVenvRuntimeEnvironment + +VENV_DEMO_ENV_NAME = "dimos-demo-venv" + + +class VenvDemoPublisher(Module): + @rpc + def render_message(self, message: str = "hello") -> str: + from dimos_demo_worker_module.runtime_dependency import decorate_message + + return decorate_message(message) + + +class VenvDemoConsumer(Module): + publisher: VenvDemoPublisher + + @rpc + def consume_message(self, message: str = "hello") -> str: + return self.publisher.render_message(message) + + +def venv_demo_blueprint( + runtime: PythonVenvRuntimeEnvironment | None = None, +): + environment = runtime or PythonVenvRuntimeEnvironment( + name=VENV_DEMO_ENV_NAME, + python_executable=Path(sys.executable), + ) + return autoconnect( + VenvDemoPublisher.blueprint(), + VenvDemoConsumer.blueprint(), + ).runtime_environments(environment).runtime_placements({VenvDemoPublisher: environment.name}) diff --git a/packages/dimos-demo-worker-module/src/dimos_demo_worker_module/runtime_dependency.py b/packages/dimos-demo-worker-module/src/dimos_demo_worker_module/runtime_dependency.py new file mode 100644 index 0000000000..18da4b26c9 --- /dev/null +++ b/packages/dimos-demo-worker-module/src/dimos_demo_worker_module/runtime_dependency.py @@ -0,0 +1,16 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +def decorate_message(message: str) -> str: + return f"worker-runtime::{message}" diff --git a/scripts/demo_venv_worker_module.py b/scripts/demo_venv_worker_module.py new file mode 100644 index 0000000000..e721747554 --- /dev/null +++ b/scripts/demo_venv_worker_module.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the demo venv-placed worker module blueprint.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import sys + +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.core.runtime_environment import PythonVenvRuntimeEnvironment + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEMO_MODULE_SRC = REPO_ROOT / "packages" / "dimos-demo-worker-module" / "src" + + +def _prepend_pythonpath(*paths: Path) -> None: + existing_pythonpath = os.environ.get("PYTHONPATH") + path_entries = [str(path) for path in paths] + if existing_pythonpath: + path_entries.append(existing_pythonpath) + os.environ["PYTHONPATH"] = os.pathsep.join(path_entries) + for path in reversed(paths): + sys.path.insert(0, str(path)) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "message", nargs="?", default="ok", help="Message to send through the demo RPC" + ) + args = parser.parse_args() + + _prepend_pythonpath(DEMO_MODULE_SRC) + + from dimos_demo_worker_module import VENV_DEMO_ENV_NAME, VenvDemoConsumer, venv_demo_blueprint + + runtime = PythonVenvRuntimeEnvironment( + name=VENV_DEMO_ENV_NAME, + python_executable=Path(sys.executable), + env={"PYTHONPATH": str(DEMO_MODULE_SRC)}, + ) + + coordinator = ModuleCoordinator.build( + venv_demo_blueprint(runtime), + {"g": {"viewer": "none", "n_workers": 1}}, + ) + try: + consumer = coordinator.get_instance(VenvDemoConsumer) + if consumer is None: + raise RuntimeError("VenvDemoConsumer was not deployed") + print(consumer.consume_message(args.message)) + finally: + coordinator.stop() + + +if __name__ == "__main__": + main() From a67ec119150758a8edeed478cd901ca24a55434d Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 21:43:25 -0700 Subject: [PATCH 091/110] feat: add agentic manipulation primitives --- dimos/conftest.py | 25 +- .../whole_body/benchmark_runtime/adapter.py | 27 + .../agentic_manipulation_module.py | 71 ++ .../manipulation/agentic_manipulation_spec.py | 38 + .../test_agentic_manipulation_module.py | 191 ++++ docs/development/runtime_sidecars.md | 70 +- .../tasks.md | 38 +- .../demo_agentic_manipulation_robosuite.py | 920 ++++++++++++++++++ 8 files changed, 1359 insertions(+), 21 deletions(-) create mode 100644 dimos/manipulation/agentic_manipulation_module.py create mode 100644 dimos/manipulation/agentic_manipulation_spec.py create mode 100644 dimos/manipulation/test_agentic_manipulation_module.py create mode 100644 scripts/benchmarks/demo_agentic_manipulation_robosuite.py diff --git a/dimos/conftest.py b/dimos/conftest.py index e3e35fdddf..780bdb5c60 100644 --- a/dimos/conftest.py +++ b/dimos/conftest.py @@ -17,6 +17,7 @@ import hashlib import os import platform +import subprocess import tempfile import threading import uuid @@ -71,6 +72,8 @@ load_dotenv() +_PYTEST_WATCHDOG_PROC: subprocess.Popen[bytes] | None = None + def _has_ros() -> bool: try: @@ -110,12 +113,32 @@ def pytest_configure(config): # Only spawn on the controller, without doing it on xdist workers. if not hasattr(config, "workerinput"): - spawn_watchdog( + global _PYTEST_WATCHDOG_PROC + _PYTEST_WATCHDOG_PROC = spawn_watchdog( os.environ[DIMOS_PYTEST_RUN_ID_ENV], env_var=DIMOS_PYTEST_RUN_ID_ENV, ) +def pytest_unconfigure(config): + del config + global _PYTEST_WATCHDOG_PROC + if _PYTEST_WATCHDOG_PROC is None: + return + + watchdog_proc = _PYTEST_WATCHDOG_PROC + _PYTEST_WATCHDOG_PROC = None + if watchdog_proc.poll() is not None: + return + + watchdog_proc.terminate() + try: + watchdog_proc.wait(timeout=2.0) + except subprocess.TimeoutExpired: + watchdog_proc.kill() + watchdog_proc.wait(timeout=2.0) + + @pytest.fixture(scope="session") def mcp_port() -> int: """The MCP server port pinned for this xdist worker (or the default).""" diff --git a/dimos/hardware/whole_body/benchmark_runtime/adapter.py b/dimos/hardware/whole_body/benchmark_runtime/adapter.py index 8ff08709d8..b9d1a17ed9 100644 --- a/dimos/hardware/whole_body/benchmark_runtime/adapter.py +++ b/dimos/hardware/whole_body/benchmark_runtime/adapter.py @@ -98,6 +98,33 @@ def write_motor_commands(self, commands: list[MotorCommand]) -> bool: return False return self._client.write_commands(commands) + def read_gripper_position(self) -> float | None: + """Expose the final runtime motor as a gripper position for demos.""" + + if not self._last_states: + return None + if self._client is not None: + try: + _, self._last_states = self._client.read_states() + except Exception: + pass + return self._last_states[-1].q + + def write_gripper_position(self, position: float) -> bool: + """Command the final runtime motor as a gripper through the SHM bridge.""" + + if self._client is None: + return False + _, states = self._client.read_states() + if len(states) != self.dof: + return False + commands = [MotorCommand(q=state.q, dq=0.0, kp=40.0, kd=3.0, tau=0.0) for state in states] + commands[-1] = MotorCommand(q=position, dq=0.0, kp=40.0, kd=3.0, tau=0.0) + ok = self._client.write_commands(commands) + if ok: + self._last_states = states + return ok + def register(registry: WholeBodyAdapterRegistry) -> None: """Register benchmark runtime adapter.""" diff --git a/dimos/manipulation/agentic_manipulation_module.py b/dimos/manipulation/agentic_manipulation_module.py new file mode 100644 index 0000000000..77f565fd22 --- /dev/null +++ b/dimos/manipulation/agentic_manipulation_module.py @@ -0,0 +1,71 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Universal agent-facing manipulation primitive facade.""" + +from __future__ import annotations + +from dimos.agents.annotation import skill +from dimos.agents.skill_result import SkillResult +from dimos.core.module import Module +from dimos.manipulation.agentic_manipulation_spec import ManipulationControlSpec +from dimos.manipulation.skill_errors import ManipulationSkillError + + +class AgenticManipulationModule(Module): + """Expose stable manipulation primitives for agent/tool callers.""" + + _manipulation: ManipulationControlSpec + + @skill + def get_robot_state(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: + """Get current robot state for manipulation. + + Args: + robot_name: Robot to query (only needed for multi-arm setups). + """ + return self._manipulation.get_robot_state(robot_name) + + @skill + def move_to_joints( + self, joints: str, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: + """Move the robot to a target joint configuration. + + Args: + joints: Comma-separated joint positions in radians, e.g. "0.1, -0.5, 1.2, 0.0, 0.3, -0.1". + robot_name: Robot to move (only needed for multi-arm setups). + """ + return self._manipulation.move_to_joints(joints, robot_name) + + @skill + def open_gripper(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: + """Open the robot gripper fully. + + Args: + robot_name: Robot to control (only needed for multi-arm setups). + """ + return self._manipulation.open_gripper(robot_name) + + @skill + def close_gripper(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: + """Close the robot gripper fully. + + Args: + robot_name: Robot to control (only needed for multi-arm setups). + """ + return self._manipulation.close_gripper(robot_name) + + +agentic_manipulation = AgenticManipulationModule.blueprint diff --git a/dimos/manipulation/agentic_manipulation_spec.py b/dimos/manipulation/agentic_manipulation_spec.py new file mode 100644 index 0000000000..d904fab37e --- /dev/null +++ b/dimos/manipulation/agentic_manipulation_spec.py @@ -0,0 +1,38 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Spec contract for agent-facing manipulation primitive providers.""" + +from __future__ import annotations + +from typing import Protocol + +from dimos.agents.skill_result import SkillResult +from dimos.manipulation.skill_errors import ManipulationSkillError +from dimos.spec.utils import Spec + + +class ManipulationControlSpec(Spec, Protocol): + def get_robot_state( + self, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: ... + def move_to_joints( + self, joints: str, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: ... + def open_gripper( + self, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: ... + def close_gripper( + self, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: ... diff --git a/dimos/manipulation/test_agentic_manipulation_module.py b/dimos/manipulation/test_agentic_manipulation_module.py new file mode 100644 index 0000000000..335093202b --- /dev/null +++ b/dimos/manipulation/test_agentic_manipulation_module.py @@ -0,0 +1,191 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Callable, Iterator +import inspect +import json +from typing import TypeAlias, cast, get_type_hints + +import pytest + +from dimos.agents.skill_result import SkillResult +from dimos.manipulation.agentic_manipulation_module import AgenticManipulationModule +from dimos.manipulation.skill_errors import ManipulationSkillError + +Call: TypeAlias = tuple[str, tuple[str | None, ...]] +GetStateMethod: TypeAlias = Callable[ + [AgenticManipulationModule, str | None], SkillResult[ManipulationSkillError] +] +MoveToJointsMethod: TypeAlias = Callable[ + [AgenticManipulationModule, str, str | None], SkillResult[ManipulationSkillError] +] + + +class FakeManipulationProvider: + def __init__(self, result: SkillResult[ManipulationSkillError]) -> None: + self.result = result + self.calls: list[Call] = [] + + def get_robot_state(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: + self.calls.append(("get_robot_state", (robot_name,))) + return self.result + + def move_to_joints( + self, joints: str, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: + self.calls.append(("move_to_joints", (joints, robot_name))) + return self.result + + def open_gripper(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: + self.calls.append(("open_gripper", (robot_name,))) + return self.result + + def close_gripper(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: + self.calls.append(("close_gripper", (robot_name,))) + return self.result + + +@pytest.fixture +def skill_result() -> SkillResult[ManipulationSkillError]: + return SkillResult[ManipulationSkillError].ok("provider result", state="ready") + + +@pytest.fixture +def provider(skill_result: SkillResult[ManipulationSkillError]) -> FakeManipulationProvider: + return FakeManipulationProvider(skill_result) + + +@pytest.fixture +def module(provider: FakeManipulationProvider) -> Iterator[AgenticManipulationModule]: + agentic_module = AgenticManipulationModule() + agentic_module._manipulation = provider + try: + yield agentic_module + finally: + agentic_module.stop() + + +def test_get_robot_state_delegates_exact_arguments_and_result( + module: AgenticManipulationModule, + provider: FakeManipulationProvider, + skill_result: SkillResult[ManipulationSkillError], +) -> None: + get_robot_state = cast("GetStateMethod", AgenticManipulationModule.get_robot_state.__wrapped__) + + result = get_robot_state(module, "left_arm") + + assert result is skill_result + assert provider.calls == [("get_robot_state", ("left_arm",))] + + +def test_move_to_joints_delegates_exact_arguments_and_result( + module: AgenticManipulationModule, + provider: FakeManipulationProvider, + skill_result: SkillResult[ManipulationSkillError], +) -> None: + joints = "0.1, -0.5, 1.2, 0.0, 0.3, -0.1" + move_to_joints = cast( + "MoveToJointsMethod", AgenticManipulationModule.move_to_joints.__wrapped__ + ) + + result = move_to_joints(module, joints, "right_arm") + + assert result is skill_result + assert provider.calls == [("move_to_joints", (joints, "right_arm"))] + + +def test_open_gripper_delegates_exact_arguments_and_result( + module: AgenticManipulationModule, + provider: FakeManipulationProvider, + skill_result: SkillResult[ManipulationSkillError], +) -> None: + open_gripper = cast("GetStateMethod", AgenticManipulationModule.open_gripper.__wrapped__) + + result = open_gripper(module, None) + + assert result is skill_result + assert provider.calls == [("open_gripper", (None,))] + + +def test_close_gripper_delegates_exact_arguments_and_result( + module: AgenticManipulationModule, + provider: FakeManipulationProvider, + skill_result: SkillResult[ManipulationSkillError], +) -> None: + close_gripper = cast("GetStateMethod", AgenticManipulationModule.close_gripper.__wrapped__) + + result = close_gripper(module, "left_arm") + + assert result is skill_result + assert provider.calls == [("close_gripper", ("left_arm",))] + + +def test_decorated_skill_call_preserves_provider_result_semantics( + module: AgenticManipulationModule, + provider: FakeManipulationProvider, + skill_result: SkillResult[ManipulationSkillError], +) -> None: + result = module.get_robot_state("left_arm") + + assert result is not skill_result + assert result.success == skill_result.success + assert result.message == skill_result.message + assert result.error_code == skill_result.error_code + assert result.metadata == skill_result.metadata + assert result.duration_ms >= 0.0 + assert provider.calls == [("get_robot_state", ("left_arm",))] + + +@pytest.mark.parametrize( + ("method_name", "expected_params"), + [ + ("get_robot_state", {"robot_name": str | None}), + ("move_to_joints", {"joints": str, "robot_name": str | None}), + ("open_gripper", {"robot_name": str | None}), + ("close_gripper", {"robot_name": str | None}), + ], +) +def test_skill_methods_have_schema_safe_metadata( + method_name: str, expected_params: dict[str, type | object] +) -> None: + method = getattr(AgenticManipulationModule, method_name) + signature = inspect.signature(method) + type_hints = get_type_hints(method) + + assert getattr(method, "__skill__", False) is True + assert inspect.getdoc(method) + for name, annotation in expected_params.items(): + assert name in signature.parameters + assert type_hints[name] == annotation + for name, parameter in signature.parameters.items(): + if name == "self": + continue + assert parameter.annotation is not inspect.Parameter.empty + assert name in expected_params + + +def test_skill_methods_generate_get_skills_schemas(module: AgenticManipulationModule) -> None: + skills = {skill.func_name: skill for skill in module.get_skills()} + + assert set(skills) == {"close_gripper", "get_robot_state", "move_to_joints", "open_gripper"} + for method_name in skills: + schema = json.loads(skills[method_name].args_schema) + assert schema["type"] == "object" + assert "properties" in schema + move_schema = json.loads(skills["move_to_joints"].args_schema) + assert "joints" in move_schema["properties"] + assert "robot_name" in move_schema["properties"] + assert move_schema["required"] == ["joints"] diff --git a/docs/development/runtime_sidecars.md b/docs/development/runtime_sidecars.md index 506b78a2e2..f48f515dbe 100644 --- a/docs/development/runtime_sidecars.md +++ b/docs/development/runtime_sidecars.md @@ -39,7 +39,9 @@ Expected output includes `"ok": true` and artifacts under Run this from an environment that can import Robosuite 1.5.x and this monorepo. The DimOS process still does not import Robosuite; the sidecar subprocess owns -Robosuite environment construction and stepping. +Robosuite environment construction and stepping. Include the `manipulation` extra +when running demos that build `ManipulationModule`, because that module's default +planning backend uses Drake. ```bash uv run --with robosuite python scripts/benchmarks/demo_robosuite_panda_lift.py @@ -137,3 +139,69 @@ writes artifacts under `artifacts/benchmark/robosuite-panda-lift/`. If Robosuite is not installed, the script exits with an explicit sidecar health failure and writes `robosuite_sidecar.log` with the import error. + +## Agentic manipulation Robosuite validation + +The agentic manipulation demo is a manual, script-hosted layer-2 validation. It +is not part of the default unit-test suite because it requires Robosuite and a +runtime sidecar process. The DimOS process still does not import Robosuite: the +script launches the Robosuite sidecar, resolves the runtime motor surface, builds +the local SHM bridge, then starts an in-script DimOS stack containing +`ControlCoordinator`, `ManipulationModule`, and `AgenticManipulationModule`. + +```bash +uv run --extra manipulation --with robosuite python scripts/benchmarks/demo_agentic_manipulation_robosuite.py +``` + +This command opens the Robosuite viewer by default so a human can watch the +primitive validation run. The visual defaults keep stepping long enough to make +the gripper open/close commands and joint motion observable. Use `--headless` +only for CI or non-GUI environments: + +```bash +uv run --extra manipulation --with robosuite python scripts/benchmarks/demo_agentic_manipulation_robosuite.py --headless +``` + +For a slightly longer manual check, run: + +```bash +uv run --extra manipulation --with robosuite python scripts/benchmarks/demo_agentic_manipulation_robosuite.py --ticks 600 --horizon 700 +``` + +The demo calls the universal agent-facing module API directly and fails hard if +`get_robot_state`, `open_gripper`, `close_gripper`, or a small safe +`move_to_joints` command does not report success. A background SHM-to-sidecar +stepping loop keeps simulator state moving while those blocking manipulation +calls execute. + +The direct RPC calls are synchronous. `move_to_joints` returns only after the +trajectory task reports completion, while `open_gripper` and `close_gripper` +return after the command has been accepted by the coordinator/adapter. The demo +therefore keeps the sidecar stepping for `--primitive-pause-s` seconds after each +gripper command and `--post-demo-s` seconds after the joint move so the viewer +does not close before those actions are visible. If you override `--ticks`, make +it large enough for those pauses or reduce the pause durations. + +Artifacts are written under the configured runtime artifact directory +(`artifacts/benchmark/robosuite-panda-lift/` by default), including the episode +config, runtime description, resolved runtime plan, derived runtime robot config, +stack summary, API call summary, motor trace, score when available, sidecar log, +and cleanup status. The script writes every artifact available even when the demo +fails partway through startup or API validation, so `api_call_summary.json`, +`motor_trace.json`, `failure.json`, and `cleanup_status.json` can be used to +diagnose partial runs. + +The stack summary also records two script-local fallbacks. First, the +`HardwareComponent` is constructed directly as `WHOLE_BODY` because +`RobotConfig.to_hardware_component()` derives manipulator hardware, while the +benchmark runtime adapter exposes a whole-body SHM motor plane. The task and +robot model still derive from `RobotConfig`. Second, the script writes a minimal +runtime URDF with conservative joint limits because the current Robosuite sidecar +description does not yet provide authoritative planning model metadata. + +`AgenticManipulationModule` itself is simulator-independent and imports only the +universal manipulation-control spec plus DimOS module/skill primitives. Robosuite +startup, runtime-plan resolution, SHM stepping, and artifact writing stay in this +script-hosted validation layer. MCP tool filtering, full LLM-agent execution, +Cartesian motion, and higher-level semantic manipulation skills remain future +work above this universal primitive facade. diff --git a/openspec/changes/add-agentic-manipulation-primitives/tasks.md b/openspec/changes/add-agentic-manipulation-primitives/tasks.md index 5a9331851a..f4aba045b8 100644 --- a/openspec/changes/add-agentic-manipulation-primitives/tasks.md +++ b/openspec/changes/add-agentic-manipulation-primitives/tasks.md @@ -1,30 +1,30 @@ ## 1. Agentic Manipulation Module -- [ ] 1.1 Add `dimos/manipulation/agentic_manipulation_spec.py` with a `ManipulationControlSpec` Protocol covering robot state, joint motion, open gripper, and close gripper. -- [ ] 1.2 Add `dimos/manipulation/agentic_manipulation_module.py` with `AgenticManipulationModule` as a `Module` facade using Spec-injected manipulation control. -- [ ] 1.3 Decorate the facade primitives with `@skill`, include required docstrings and type annotations, and return the delegated `SkillResult` values. -- [ ] 1.4 Ensure the new module has no imports from Robosuite, benchmark runtime clients, sidecar packages, or script-only benchmark code. +- [x] 1.1 Add `dimos/manipulation/agentic_manipulation_spec.py` with a `ManipulationControlSpec` Protocol covering robot state, joint motion, open gripper, and close gripper. +- [x] 1.2 Add `dimos/manipulation/agentic_manipulation_module.py` with `AgenticManipulationModule` as a `Module` facade using Spec-injected manipulation control. +- [x] 1.3 Decorate the facade primitives with `@skill`, include required docstrings and type annotations, and return the delegated `SkillResult` values. +- [x] 1.4 Ensure the new module has no imports from Robosuite, benchmark runtime clients, sidecar packages, or script-only benchmark code. ## 2. Simulator-Free Tests -- [ ] 2.1 Add default unit tests for `AgenticManipulationModule` using a fake injected manipulation provider. -- [ ] 2.2 Verify each primitive delegates the expected arguments to the fake provider and passes the provider result back to the caller. -- [ ] 2.3 Verify the skill methods have schema-safe signatures and docstrings suitable for MCP exposure. -- [ ] 2.4 Run the new unit tests without Robosuite dependencies. +- [x] 2.1 Add default unit tests for `AgenticManipulationModule` using a fake injected manipulation provider. +- [x] 2.2 Verify each primitive delegates the expected arguments to the fake provider and passes the provider result back to the caller. +- [x] 2.3 Verify the skill methods have schema-safe signatures and docstrings suitable for MCP exposure. +- [x] 2.4 Run the new unit tests without Robosuite dependencies. ## 3. Robosuite Layer-2 Demo -- [ ] 3.1 Add `scripts/benchmarks/demo_agentic_manipulation_robosuite.py` following the sidecar startup, health wait, describe/reset, artifact, and cleanup structure from `demo_robosuite_panda_lift.py`. -- [ ] 3.2 Build a dynamic pre-blueprint robot configuration from the resolved Robosuite runtime plan and derive the `HardwareComponent`, coordinator `TaskConfig`, and `RobotModelConfig` needed by the stack. -- [ ] 3.3 Construct the in-script blueprint with `ControlCoordinator`, `ManipulationModule`, and `AgenticManipulationModule`, then start it with `ModuleCoordinator.build(...)`. -- [ ] 3.4 Implement a background SHM-to-sidecar stepping loop so blocking manipulation API calls can progress simulator state. -- [ ] 3.5 Call the `AgenticManipulationModule` API directly and fail hard unless robot state, open gripper, close gripper, and a safe small-offset joint motion report success. -- [ ] 3.6 Write episode config, runtime description, resolved runtime plan, API call summary, motor trace, score when available, sidecar log, and cleanup status artifacts. +- [x] 3.1 Add `scripts/benchmarks/demo_agentic_manipulation_robosuite.py` following the sidecar startup, health wait, describe/reset, artifact, and cleanup structure from `demo_robosuite_panda_lift.py`. +- [x] 3.2 Build a dynamic pre-blueprint robot configuration from the resolved Robosuite runtime plan and derive the `HardwareComponent`, coordinator `TaskConfig`, and `RobotModelConfig` needed by the stack. +- [x] 3.3 Construct the in-script blueprint with `ControlCoordinator`, `ManipulationModule`, and `AgenticManipulationModule`, then start it with `ModuleCoordinator.build(...)`. +- [x] 3.4 Implement a background SHM-to-sidecar stepping loop so blocking manipulation API calls can progress simulator state. +- [x] 3.5 Call the `AgenticManipulationModule` API directly and fail hard unless robot state, open gripper, close gripper, and a safe small-offset joint motion report success. +- [x] 3.6 Write episode config, runtime description, resolved runtime plan, API call summary, motor trace, score when available, sidecar log, and cleanup status artifacts. ## 4. Documentation and Validation -- [ ] 4.1 Document the Robosuite demo command and clarify that it is manual/script-hosted layer-2 validation, not a default unit test. -- [ ] 4.2 Document that the universal module is simulator-independent and that MCP filtering, LLM agent execution, Cartesian motion, and higher-level semantic manipulation skills are future work. -- [ ] 4.3 Run `uv run pytest dimos/manipulation/test_agentic_manipulation_module.py -v`. -- [ ] 4.4 If Robosuite dependencies are available, run the new script-hosted Robosuite demo and inspect its artifacts. -- [ ] 4.5 Run OpenSpec validation for this change and fix any proposal, spec, design, or task formatting issues. +- [x] 4.1 Document the Robosuite demo command and clarify that it is manual/script-hosted layer-2 validation, not a default unit test. +- [x] 4.2 Document that the universal module is simulator-independent and that MCP filtering, LLM agent execution, Cartesian motion, and higher-level semantic manipulation skills are future work. +- [x] 4.3 Run `uv run pytest dimos/manipulation/test_agentic_manipulation_module.py -v`. +- [x] 4.4 If Robosuite dependencies are available, run the new script-hosted Robosuite demo and inspect its artifacts. Ran `uv run --extra manipulation --with robosuite python scripts/benchmarks/demo_agentic_manipulation_robosuite.py --ticks 60 --horizon 120` successfully and inspected the API summary and cleanup artifacts, then updated the visual command to keep stepping after the blocking RPCs. The demo now opens the Robosuite viewer by default; use `--headless` for automation. For visible manual checks, use the default command or `--ticks 600 --horizon 700` instead of very short tick budgets. +- [x] 4.5 Run OpenSpec validation for this change and fix any proposal, spec, design, or task formatting issues. diff --git a/scripts/benchmarks/demo_agentic_manipulation_robosuite.py b/scripts/benchmarks/demo_agentic_manipulation_robosuite.py new file mode 100644 index 0000000000..9c1ee0e36d --- /dev/null +++ b/scripts/benchmarks/demo_agentic_manipulation_robosuite.py @@ -0,0 +1,920 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run layer-2 Robosuite validation through AgenticManipulationModule. + +The script keeps Robosuite out of the DimOS process by launching the Robosuite +sidecar subprocess, then builds a DimOS stack with the benchmark-runtime SHM +adapter, ControlCoordinator, ManipulationModule, and AgenticManipulationModule. +It calls the agent-facing module API directly and writes artifacts describing +the episode, runtime plan, API results, motor trace, score, sidecar log, and +cleanup status. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable +from dataclasses import dataclass +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import time + +REPO_ROOT = Path(__file__).resolve().parents[2] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +ROBOSUITE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src" + +for package_src in (PROTOCOL_SRC, ROBOSUITE_SIDECAR_SRC): + sys.path.insert(0, str(package_src)) + +from dimos_runtime_protocol import EpisodeResetRequest, MotorActionFrame, StepRequest + +from dimos.benchmark.runtime.artifacts import write_json +from dimos.benchmark.runtime.config import ( + BenchmarkEpisodeConfig, + ResolvedRuntimePlan, + resolve_runtime_plan, +) +from dimos.control.components import HardwareComponent, HardwareType +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.hardware.whole_body.spec import MotorState, WholeBodyConfig +from dimos.manipulation.agentic_manipulation_module import AgenticManipulationModule +from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.robot.config import GripperConfig, RobotConfig +from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient +from dimos.simulation.runtime_client.shm_motor import MotorShmOwner + + +@dataclass(frozen=True) +class RuntimeRobotStackConfig: + """DimOS stack config derived from a Robosuite runtime plan.""" + + robot: RobotConfig + hardware: HardwareComponent + task: TaskConfig + model: RobotModelConfig + + +@dataclass(frozen=True) +class ApiCallRecord: + """Summary of one direct AgenticManipulationModule API call.""" + + name: str + success: bool + message: str + duration_ms: float | None + + +def _load_config(path: Path) -> BenchmarkEpisodeConfig: + return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) + + +def _ensure_drake_available() -> None: + if importlib.util.find_spec("pydrake") is None: + raise RuntimeError( + "Agentic manipulation Robosuite demo requires the DimOS manipulation planning " + "extra because ManipulationModule uses the Drake planning backend in this demo. " + "Run: uv run --extra manipulation --with robosuite python " + "scripts/benchmarks/demo_agentic_manipulation_robosuite.py" + ) + + +def _sidecar_env() -> dict[str, str]: + env = dict(os.environ) + existing = env.get("PYTHONPATH", "") + paths = [str(PROTOCOL_SRC), str(ROBOSUITE_SIDECAR_SRC)] + if existing: + paths.append(existing) + env["PYTHONPATH"] = os.pathsep.join(paths) + return env + + +def _start_robosuite_sidecar(config: BenchmarkEpisodeConfig) -> subprocess.Popen[str]: + command = [ + sys.executable, + "-m", + "dimos_robosuite_sidecar.server", + "--host", + config.runtime_host, + "--port", + str(config.runtime_port), + "--env-name", + config.env_name, + "--robot-id", + config.robot_id, + "--robot-model", + config.robot_model, + "--controller", + config.controller, + "--control-freq", + str(config.control_step_hz), + "--horizon", + str(config.horizon), + "--camera-name", + config.camera_name, + "--seed", + str(config.seed) if config.seed is not None else "0", + ] + if config.visualize: + command.append("--visualize") + return subprocess.Popen( + command, + cwd=REPO_ROOT, + env=_sidecar_env(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +def _wait_sidecar_healthy( + sidecar: subprocess.Popen[str], + client: RuntimeSidecarClient, + timeout_s: float, +) -> object: + deadline = time.monotonic() + timeout_s + last_error = "" + while time.monotonic() < deadline: + if sidecar.poll() is not None: + raise RuntimeError("Robosuite sidecar exited before becoming healthy") + try: + return client.health() + except Exception as exc: + last_error = str(exc) + time.sleep(0.1) + raise RuntimeError(f"Robosuite sidecar did not become healthy: {last_error}") + + +def _command_frame(owner: MotorShmOwner, robot_id: str) -> tuple[int, MotorActionFrame]: + sequence, commands = owner.read_commands() + return sequence, MotorActionFrame( + robot_id=robot_id, + names=owner.motor_names, + q=[command.q for command in commands], + dq=[command.dq for command in commands], + kp=[command.kp for command in commands], + kd=[command.kd for command in commands], + tau=[command.tau for command in commands], + sequence=sequence, + ) + + +def _local_motor_names(plan: ResolvedRuntimePlan) -> list[str]: + prefix = f"{plan.robot_id}/" + local_names: list[str] = [] + for motor_name in plan.motor_names: + if motor_name.startswith(prefix): + local_names.append(motor_name[len(prefix) :]) + else: + local_names.append(motor_name) + return local_names + + +def _write_minimal_urdf(path: Path, joint_names: list[str]) -> None: + links = [' '] + joints: list[str] = [] + parent = "base_link" + for index, joint_name in enumerate(joint_names, start=1): + child = f"link_{index}" + links.append(f' ') + joints.extend( + [ + f' ', + f' ', + f' ', + ' ', + ' ', + ' ', + " ", + ] + ) + parent = child + xml = [ + '', + '', + *links, + *joints, + "", + "", + ] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(xml)) + + +def _derive_runtime_robot_stack( + plan: ResolvedRuntimePlan, + artifact_dir: Path, +) -> RuntimeRobotStackConfig: + local_joint_names = _local_motor_names(plan) + if len(local_joint_names) != len(plan.motor_names): + raise ValueError("local/global motor name count mismatch") + + model_path = artifact_dir / "runtime_robot.urdf" + _write_minimal_urdf(model_path, local_joint_names) + gripper_joint = local_joint_names[-1] + robot = RobotConfig( + name=plan.robot_id, + model_path=model_path, + joint_names=local_joint_names, + base_link="base_link", + end_effector_link=f"link_{len(local_joint_names)}", + adapter_type="benchmark_runtime", + address=plan.shm_key, + adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, + home_joints=[0.0] * len(local_joint_names), + gripper=GripperConfig(type="runtime_motor", joints=[gripper_joint]), + ) + hardware = HardwareComponent( + hardware_id=plan.robot_id, + hardware_type=HardwareType.WHOLE_BODY, + joints=plan.motor_names, + adapter_type="benchmark_runtime", + address=plan.shm_key, + adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, + wb_config=WholeBodyConfig( + kp=tuple(40.0 for _ in plan.motor_names), + kd=tuple(3.0 for _ in plan.motor_names), + ), + ) + return RuntimeRobotStackConfig( + robot=robot, + hardware=hardware, + task=robot.to_task_config(task_type="trajectory"), + model=robot.to_robot_model_config(), + ) + + +class SidecarSteppingLoop: + """Background SHM-to-sidecar loop for blocking manipulation API calls.""" + + def __init__( + self, + *, + client: RuntimeSidecarClient, + owner: MotorShmOwner, + plan: ResolvedRuntimePlan, + ) -> None: + self._client = client + self._owner = owner + self._plan = plan + self._stop_event = threading.Event() + self._thread = threading.Thread( + target=self._run, name="robosuite-sidecar-step", daemon=True + ) + self._trace: list[dict[str, object]] = [] + self._error: str | None = None + + @property + def trace(self) -> list[dict[str, object]]: + return list(self._trace) + + @property + def error(self) -> str | None: + return self._error + + @property + def tick_count(self) -> int: + return len(self._trace) + + @property + def is_alive(self) -> bool: + return self._thread.is_alive() + + def start(self) -> None: + self._thread.start() + + def stop(self) -> None: + self._stop_event.set() + self._thread.join(timeout=5.0) + + def _run(self) -> None: + tick = 0 + period_s = 1.0 / float(self._plan.control_step_hz) + while not self._stop_event.is_set() and tick < self._plan.ticks: + try: + command_sequence, action = _command_frame(self._owner, self._plan.robot_id) + response = self._client.step( + StepRequest(episode_id=self._plan.episode_id, tick_id=tick, action=action) + ) + self._owner.write_state( + [ + MotorState( + q=response.motor_state.q[i], + dq=response.motor_state.dq[i], + tau=response.motor_state.tau[i], + ) + for i in range(len(self._plan.motor_names)) + ], + sequence=response.motor_state.sequence, + ) + self._trace.append( + { + "tick": tick, + "command_sequence": command_sequence, + "state_sequence": response.motor_state.sequence, + "command_q": action.q, + "state_q": response.motor_state.q, + "reward": response.reward, + "done": response.done, + "success": response.success, + } + ) + if response.done: + break + tick += 1 + time.sleep(period_s) + except Exception as exc: + self._error = str(exc) + break + + +def _call_agentic_api( + name: str, + call: Callable[[], object], + records: list[ApiCallRecord], +) -> None: + result = call() + success_attr = getattr(result, "success", False) + success = bool(success_attr) + message_attr = getattr(result, "message", "") + duration_attr = getattr(result, "duration_ms", None) + records.append( + ApiCallRecord( + name=name, + success=success, + message=str(message_attr), + duration_ms=float(duration_attr) if isinstance(duration_attr, int | float) else None, + ) + ) + if not success: + raise RuntimeError(f"AgenticManipulationModule.{name} failed: {message_attr}") + + +def _wait_for_sidecar_ticks( + stepping_loop: SidecarSteppingLoop, + *, + seconds: float, + control_step_hz: float, + label: str, +) -> None: + """Let Robosuite advance for a visible amount of simulated time.""" + + if seconds <= 0.0: + return + start_tick = stepping_loop.tick_count + target_ticks = max(1, round(seconds * control_step_hz)) + deadline = time.monotonic() + max(seconds * 4.0, seconds + 2.0) + while stepping_loop.tick_count - start_tick < target_ticks: + if stepping_loop.error is not None: + raise RuntimeError( + f"sidecar stepping loop failed during {label}: {stepping_loop.error}" + ) + if not stepping_loop.is_alive: + raise RuntimeError( + f"sidecar stepping loop ended while waiting after {label}; " + "increase --ticks or reduce the visual pause durations" + ) + if time.monotonic() > deadline: + raise RuntimeError(f"timed out waiting for Robosuite ticks after {label}") + time.sleep(0.02) + + +def _small_offset_target(current_positions: list[float], offset: float) -> str: + target = list(current_positions) + if target: + target[0] += offset + return ", ".join(f"{value:.4f}" for value in target) + + +def _latest_state_positions(stepping_loop: SidecarSteppingLoop, motor_count: int) -> list[float]: + trace = stepping_loop.trace + if not trace: + return [0.0] * motor_count + state_q = trace[-1].get("state_q", []) + if not isinstance(state_q, list): + return [0.0] * motor_count + positions = [float(value) for value in state_q] + if len(positions) != motor_count: + return [0.0] * motor_count + return positions + + +def _hardware_summary(hardware: HardwareComponent) -> dict[str, object]: + wb_config = hardware.wb_config + return { + "hardware_id": hardware.hardware_id, + "hardware_type": hardware.hardware_type.value, + "joints": hardware.joints, + "adapter_type": hardware.adapter_type, + "address": str(hardware.address) if hardware.address is not None else None, + "auto_enable": hardware.auto_enable, + "gripper_joints": hardware.gripper_joints, + "domain_id": hardware.domain_id, + "adapter_kwargs": hardware.adapter_kwargs, + "wb_config": { + "kp": list(wb_config.kp) if wb_config.kp is not None else None, + "kd": list(wb_config.kd) if wb_config.kd is not None else None, + } + if wb_config is not None + else None, + "derivation_note": ( + "Constructed directly as WHOLE_BODY because RobotConfig.to_hardware_component() " + "derives MANIPULATOR hardware, while the benchmark runtime adapter exposes a " + "whole-body SHM motor plane. TaskConfig and RobotModelConfig still derive from RobotConfig." + ), + } + + +def _gripper_summary(gripper: GripperConfig | None) -> dict[str, object] | None: + if gripper is None: + return None + return { + "type": gripper.type, + "joints": gripper.joints, + "collision_exclusions": [list(pair) for pair in gripper.collision_exclusions], + "open_position": gripper.open_position, + "close_position": gripper.close_position, + } + + +def _robot_config_summary(robot: RobotConfig) -> dict[str, object]: + return { + "name": robot.name, + "model_path": str(robot.model_path) if robot.model_path is not None else None, + "end_effector_link": robot.end_effector_link, + "height_clearance": robot.height_clearance, + "width_clearance": robot.width_clearance, + "adapter_type": robot.adapter_type, + "address": robot.address, + "adapter_kwargs": robot.adapter_kwargs, + "auto_enable": robot.auto_enable, + "joint_names": robot.joint_names, + "base_link": robot.base_link, + "home_joints": robot.home_joints, + "base_pose": robot.base_pose, + "strip_model_world_joint": robot.strip_model_world_joint, + "max_velocity": robot.max_velocity, + "max_acceleration": robot.max_acceleration, + "pre_grasp_offset": robot.pre_grasp_offset, + "gripper": _gripper_summary(robot.gripper), + "package_paths": {key: str(path) for key, path in robot.package_paths.items()}, + "xacro_args": robot.xacro_args, + "auto_convert_meshes": robot.auto_convert_meshes, + "srdf_path": str(robot.srdf_path) if robot.srdf_path is not None else None, + "tf_extra_links": robot.tf_extra_links, + "task_type": robot.task_type, + "task_priority": robot.task_priority, + "collision_exclusion_pairs": [list(pair) for pair in robot.collision_exclusion_pairs], + } + + +def _task_summary(task: TaskConfig) -> dict[str, object]: + return { + "name": task.name, + "type": task.type, + "joint_names": task.joint_names, + "priority": task.priority, + "auto_start": task.auto_start, + "params": task.params, + } + + +def _robot_model_summary(model: RobotModelConfig) -> dict[str, object]: + return { + "name": model.name, + "model_path": str(model.model_path), + "srdf_path": str(model.srdf_path) if model.srdf_path is not None else None, + "base_pose": { + "position": [ + model.base_pose.position.x, + model.base_pose.position.y, + model.base_pose.position.z, + ], + "orientation": [ + model.base_pose.orientation.x, + model.base_pose.orientation.y, + model.base_pose.orientation.z, + model.base_pose.orientation.w, + ], + "frame_id": model.base_pose.frame_id, + "ts": model.base_pose.ts, + }, + "strip_model_world_joint": model.strip_model_world_joint, + "joint_names": model.joint_names, + "end_effector_link": model.end_effector_link, + "base_link": model.base_link, + "planning_groups": [ + { + "name": group.name, + "joint_names": list(group.joint_names), + "base_link": group.base_link, + "tip_link": group.tip_link, + "source": group.source, + } + for group in model.planning_groups + ], + "package_paths": {key: str(path) for key, path in model.package_paths.items()}, + "joint_limits_lower": model.joint_limits_lower, + "joint_limits_upper": model.joint_limits_upper, + "velocity_limits": model.velocity_limits, + "auto_convert_meshes": model.auto_convert_meshes, + "xacro_args": model.xacro_args, + "collision_exclusion_pairs": [list(pair) for pair in model.collision_exclusion_pairs], + "max_velocity": model.max_velocity, + "max_acceleration": model.max_acceleration, + "coordinator_task_name": model.coordinator_task_name, + "gripper_hardware_id": model.gripper_hardware_id, + "tf_extra_links": model.tf_extra_links, + "home_joints": model.home_joints, + "pre_grasp_offset": model.pre_grasp_offset, + "fallback_model_note": ( + "Script-generated minimal URDF for runtime validation only. Joint limits are " + "conservative to cover Robosuite reset states until sidecar metadata can provide " + "authoritative model limits." + ), + } + + +def _api_call_summary(records: list[ApiCallRecord]) -> list[dict[str, object]]: + return [ + { + "name": record.name, + "success": record.success, + "message": record.message, + "duration_ms": record.duration_ms, + } + for record in records + ] + + +def _write_json_artifact( + path: Path, + payload: object, + errors: dict[str, str], +) -> None: + try: + write_json(path, payload) + except Exception as exc: + errors[path.name] = str(exc) + + +def _write_available_artifacts( + *, + artifact_dir: Path, + config: BenchmarkEpisodeConfig, + health: object | None, + description: object | None, + plan: ResolvedRuntimePlan | None, + reset: object | None, + stack_config: RuntimeRobotStackConfig | None, + api_calls: list[ApiCallRecord], + stepping_loop: SidecarSteppingLoop | None, + score: object | None, + failure: str | None, +) -> dict[str, str]: + """Write every validation artifact available on success or failure.""" + + errors: dict[str, str] = {} + _write_json_artifact(artifact_dir / "episode_config.json", config, errors) + if health is not None: + _write_json_artifact(artifact_dir / "health.json", health, errors) + if description is not None: + _write_json_artifact(artifact_dir / "runtime_description.json", description, errors) + if plan is not None: + _write_json_artifact(artifact_dir / "resolved_runtime_plan.json", plan, errors) + if reset is not None: + _write_json_artifact(artifact_dir / "reset_response.json", reset, errors) + if stack_config is not None: + _write_json_artifact( + artifact_dir / "runtime_robot_config.json", + _robot_config_summary(stack_config.robot), + errors, + ) + _write_json_artifact( + artifact_dir / "stack_config_summary.json", + { + "hardware": _hardware_summary(stack_config.hardware), + "task": _task_summary(stack_config.task), + "robot_model": _robot_model_summary(stack_config.model), + }, + errors, + ) + _write_json_artifact( + artifact_dir / "api_call_summary.json", _api_call_summary(api_calls), errors + ) + _write_json_artifact( + artifact_dir / "motor_trace.json", + stepping_loop.trace if stepping_loop is not None else [], + errors, + ) + _write_json_artifact( + artifact_dir / "score.json", + score if score is not None else {"available": False, "reason": "not reached"}, + errors, + ) + if failure is not None: + _write_json_artifact(artifact_dir / "failure.json", {"reason": failure}, errors) + return errors + + +def _build_stack(stack_config: RuntimeRobotStackConfig, tick_rate: float) -> ModuleCoordinator: + blueprint = autoconnect( + ControlCoordinator.blueprint( + tick_rate=tick_rate, + publish_joint_state=True, + hardware=[stack_config.hardware], + tasks=[stack_config.task], + ), + ManipulationModule.blueprint( + robots=[stack_config.model], + world_backend="drake", + planner_name="rrt_connect", + coordinator_rpc_timeout=10.0, + ), + AgenticManipulationModule.blueprint(), + ) + return ModuleCoordinator.build(blueprint) + + +def run_demo_config( + config: BenchmarkEpisodeConfig, + *, + move_offset: float = 0.25, + settle_s: float = 0.25, + primitive_pause_s: float = 1.0, + post_demo_s: float = 2.0, +) -> Path: + return _run_demo( + config, + move_offset=move_offset, + settle_s=settle_s, + primitive_pause_s=primitive_pause_s, + post_demo_s=post_demo_s, + ) + + +def run_demo(config_path: Path) -> Path: + return run_demo_config(_load_config(config_path)) + + +def _run_demo( + config: BenchmarkEpisodeConfig, + *, + move_offset: float, + settle_s: float, + primitive_pause_s: float, + post_demo_s: float, +) -> Path: + _ensure_drake_available() + + artifact_dir = (REPO_ROOT / config.artifact_dir).resolve() + artifact_dir.mkdir(parents=True, exist_ok=True) + client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") + sidecar: subprocess.Popen[str] | None = None + owner: MotorShmOwner | None = None + coordinator: ModuleCoordinator | None = None + stepping_loop: SidecarSteppingLoop | None = None + health: object | None = None + description: object | None = None + plan: ResolvedRuntimePlan | None = None + reset: object | None = None + stack_config: RuntimeRobotStackConfig | None = None + score: object | None = None + api_calls: list[ApiCallRecord] = [] + failure: str | None = None + cleanup_status: dict[str, object] = { + "module_coordinator_stopped": False, + "stepping_loop_stopped": False, + "shm_unlinked": False, + "sidecar_stopped": False, + } + sidecar_output = "" + try: + sidecar = _start_robosuite_sidecar(config) + try: + health = _wait_sidecar_healthy(sidecar, client, timeout_s=20.0) + except RuntimeError as exc: + raise RuntimeError( + "Robosuite sidecar did not become healthy. If this environment does not " + "have Robosuite installed, run this script in the Robosuite sidecar env." + ) from exc + description = client.describe() + plan = resolve_runtime_plan(config, description) + reset = client.reset( + EpisodeResetRequest(episode_id=plan.episode_id, task_id=plan.task_id, seed=config.seed) + ) + stack_config = _derive_runtime_robot_stack(plan, artifact_dir) + + owner = MotorShmOwner(plan.shm_key, plan.motor_names) + owner.write_state([MotorState(q=0.0) for _ in plan.motor_names], sequence=0) + + coordinator = _build_stack(stack_config, float(plan.control_step_hz)) + agentic_module = coordinator.get_instance(AgenticManipulationModule) + stepping_loop = SidecarSteppingLoop(client=client, owner=owner, plan=plan) + stepping_loop.start() + + # Allow the coordinator to publish initial joint state into ManipulationModule. + _wait_for_sidecar_ticks( + stepping_loop, + seconds=settle_s, + control_step_hz=float(plan.control_step_hz), + label="initial state propagation", + ) + + _call_agentic_api("get_robot_state", agentic_module.get_robot_state, api_calls) + _call_agentic_api("open_gripper", agentic_module.open_gripper, api_calls) + _wait_for_sidecar_ticks( + stepping_loop, + seconds=primitive_pause_s, + control_step_hz=float(plan.control_step_hz), + label="open_gripper", + ) + _call_agentic_api("close_gripper", agentic_module.close_gripper, api_calls) + _wait_for_sidecar_ticks( + stepping_loop, + seconds=primitive_pause_s, + control_step_hz=float(plan.control_step_hz), + label="close_gripper", + ) + target_joints = _small_offset_target( + _latest_state_positions(stepping_loop, len(plan.motor_names)), move_offset + ) + _call_agentic_api( + "move_to_joints", + lambda: agentic_module.move_to_joints(target_joints), + api_calls, + ) + _wait_for_sidecar_ticks( + stepping_loop, + seconds=post_demo_s, + control_step_hz=float(plan.control_step_hz), + label="move_to_joints hold", + ) + if stepping_loop.error is not None: + raise RuntimeError(f"sidecar stepping loop failed: {stepping_loop.error}") + + try: + score = client.score() + except Exception as exc: + score = {"available": False, "error": str(exc)} + + return artifact_dir + except Exception as exc: + failure = str(exc) + raise + finally: + if stepping_loop is not None: + try: + stepping_loop.stop() + cleanup_status["stepping_loop_stopped"] = True + cleanup_status["stepping_loop_error"] = stepping_loop.error + except Exception as exc: + cleanup_status["stepping_loop_stop_error"] = str(exc) + if coordinator is not None: + try: + coordinator.stop() + cleanup_status["module_coordinator_stopped"] = True + except Exception as exc: + cleanup_status["module_coordinator_error"] = str(exc) + if owner is not None: + try: + owner.close() + owner.unlink() + cleanup_status["shm_unlinked"] = True + except Exception as exc: + cleanup_status["shm_error"] = str(exc) + if sidecar is not None: + sidecar.terminate() + try: + sidecar_output, _ = sidecar.communicate(timeout=2.0) + except subprocess.TimeoutExpired: + sidecar.kill() + sidecar_output, _ = sidecar.communicate(timeout=2.0) + cleanup_status["sidecar_returncode"] = sidecar.returncode + cleanup_status["sidecar_stopped"] = sidecar.returncode is not None + else: + cleanup_status["sidecar_returncode"] = None + sidecar_log = artifact_dir / "robosuite_sidecar.log" + sidecar_log.parent.mkdir(parents=True, exist_ok=True) + sidecar_log.write_text(sidecar_output) + artifact_errors = _write_available_artifacts( + artifact_dir=artifact_dir, + config=config, + health=health, + description=description, + plan=plan, + reset=reset, + stack_config=stack_config, + api_calls=api_calls, + stepping_loop=stepping_loop, + score=score, + failure=failure, + ) + cleanup_status["artifact_write_errors"] = artifact_errors + write_json(sidecar_log.parent / "cleanup_status.json", cleanup_status) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", + type=Path, + default=REPO_ROOT + / "dimos" + / "benchmark" + / "runtime" + / "configs" + / "robosuite_panda_lift.json", + ) + parser.add_argument("--ticks", type=int, default=None, help="Override configured tick count.") + parser.add_argument("--horizon", type=int, default=None, help="Override episode horizon.") + parser.add_argument( + "--move-offset", + type=float, + default=0.25, + help="Safe offset applied to the first motor for the direct move_to_joints call.", + ) + parser.add_argument( + "--settle-s", + type=float, + default=0.25, + help="Seconds of sidecar stepping before the first API call so initial state propagates.", + ) + parser.add_argument( + "--primitive-pause-s", + type=float, + default=1.0, + help="Seconds to keep stepping after open_gripper and close_gripper for visual inspection.", + ) + parser.add_argument( + "--post-demo-s", + type=float, + default=2.0, + help="Seconds to keep the viewer alive after move_to_joints completes.", + ) + visual_group = parser.add_mutually_exclusive_group() + visual_group.add_argument( + "--visual", + dest="visualize", + action="store_true", + default=True, + help="Open the Robosuite viewer through the sidecar. This is the default.", + ) + visual_group.add_argument( + "--headless", + dest="visualize", + action="store_false", + help="Disable the Robosuite viewer for CI or non-GUI environments.", + ) + args = parser.parse_args() + try: + config = _load_config(args.config) + updates: dict[str, object] = {"visualize": args.visualize} + if args.ticks is not None: + updates["ticks"] = args.ticks + else: + visible_seconds = ( + args.settle_s + (2.0 * args.primitive_pause_s) + args.post_demo_s + 1.0 + ) + updates["ticks"] = max(config.ticks, int(config.control_step_hz * visible_seconds)) + if args.horizon is not None: + updates["horizon"] = args.horizon + else: + updates["horizon"] = max(config.horizon, int(updates["ticks"]) + 1) + if updates: + config = BenchmarkEpisodeConfig.model_validate({**config.model_dump(), **updates}) + artifact_dir = run_demo_config( + config, + move_offset=args.move_offset, + settle_s=args.settle_s, + primitive_pause_s=args.primitive_pause_s, + post_demo_s=args.post_demo_s, + ) + except Exception as exc: + print(json.dumps({"ok": False, "reason": str(exc)}, indent=2)) + sys.exit(2) + print(json.dumps({"ok": True, "artifact_dir": str(artifact_dir)}, indent=2)) + + +if __name__ == "__main__": + main() From d413f7137fecc8708ab3b813b083e462f042da62 Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 26 Jun 2026 22:05:35 -0700 Subject: [PATCH 092/110] spec: trajectory-parametrization --- CONTEXT.md | 16 +++ .../design.md | 125 ++++++++++++++++++ .../proposal.md | 35 +++++ .../spec.md | 97 ++++++++++++++ .../tasks.md | 40 ++++++ 5 files changed, 313 insertions(+) create mode 100644 openspec/changes/add-manipulation-trajectory-parametrization-spec/design.md create mode 100644 openspec/changes/add-manipulation-trajectory-parametrization-spec/proposal.md create mode 100644 openspec/changes/add-manipulation-trajectory-parametrization-spec/specs/manipulation-trajectory-parametrization/spec.md create mode 100644 openspec/changes/add-manipulation-trajectory-parametrization-spec/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md index 05bfd0f6be..da72acc8af 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,22 @@ _Avoid_: combined URDF, merged robot scene The authoritative belief state for manipulation planning, including robot state and scene state used by planners. _Avoid_: planner context, backend instance +**Trajectory parametrization**: +The manipulation-planning capability that assigns time to a geometric joint path under motion constraints. +_Avoid_: trajectory generation, retiming when referring to the broader capability + +**Generated trajectory**: +A manipulation-planning artifact that represents a geometric path after trajectory parametrization, ready for preview, validation, benchmarking, or execution planning. +_Avoid_: timed generated plan, generated plan when referring to the time-parametrized artifact, joint trajectory when referring to the manipulation-level artifact + +**Shared trajectory time domain**: +The single timing basis a generated trajectory uses for all selected joints and robot-local projections in a composite or multi-robot motion. +_Avoid_: independent per-robot timing, per-arm retiming when referring to coordinated composite motion + +**Trajectory dispatch**: +An execution-preparation artifact that derives control-task-specific joint trajectory messages from a generated trajectory without changing the generated trajectory's canonical global timing. +_Avoid_: generated trajectory projection, execution-time parametrization, per-task generated trajectory + **Robokin kinematics backend**: A DimOS kinematics backend that presents multiple robokin-supported inverse-kinematics engines through one robotics-facing capability. _Avoid_: Oink backend, RoboKin world backend, single-engine Oink solver diff --git a/openspec/changes/add-manipulation-trajectory-parametrization-spec/design.md b/openspec/changes/add-manipulation-trajectory-parametrization-spec/design.md new file mode 100644 index 0000000000..54b1e4b8fd --- /dev/null +++ b/openspec/changes/add-manipulation-trajectory-parametrization-spec/design.md @@ -0,0 +1,125 @@ +## Context + +The current manipulation pipeline separates kinematics and geometric planning through the existing multi-spec architecture, but trajectory timing is still bolted on inside execution. `GeneratedPlan` stores the selected planning groups and geometric joint path. `ManipulationModule.execute_plan()` then projects that path into robot-local paths, calls the current joint trajectory generator, wraps each result in a low-level `JointTrajectory`, and invokes coordinator tasks. + +This makes time parametrization hard to reason about. It is not a named capability, it has no explicit status, it is not available to preview or benchmarking as a stable artifact, and it can silently retime coordinated multi-robot paths independently per robot. The current generator also uses a simple per-segment trapezoidal profile that likely stops at intermediate planner waypoints and lacks explicit backend policy such as TOPPRA gridpoint selection. + +## Goals / Non-Goals + +**Goals:** + +- Introduce trajectory parametrization as a first-class manipulation-planning spec role. +- Preserve `GeneratedPlan` as a geometric planning artifact. +- Add `GeneratedTrajectory` as a global time-parametrized artifact produced from a `GeneratedPlan`. +- Add `TrajectoryDispatch` as the separate execution-preparation boundary from generated trajectory to control-task messages. +- Preserve one shared time domain across all selected joints in composite and multi-robot motions. +- Ensure preview, validation, benchmarking, and execution dispatch all consume the same `GeneratedTrajectory`. +- Keep a required `simple_trapezoid` backend that wraps current behavior and a TOPPRA backend available through an optional install extra. +- Expose backend policy controls for velocity, acceleration, minimum segment duration, and TOPPRA gridpoints/discretization. +- Distinguish planning, parametrization, dispatch, and execution failure semantics. + +**Non-Goals:** + +- Making TOPPRA the default backend in this change. +- Changing geometric planner algorithms such as RRT or IK. +- Reworking `ControlCoordinator` task internals. +- Adding a jerk-limited or S-curve backend unless it falls out naturally from a selected backend. +- Overhauling the benchmark harness beyond consuming the new generated trajectory artifact where relevant. + +## Decisions + +### TrajectoryParametrizerSpec as a new multi-spec role + +Add a `TrajectoryParametrizerSpec` Protocol that converts a successful geometric `GeneratedPlan` plus parametrization policy into a `GeneratedTrajectory`. + +Rationale: kinematics, planning, and trajectory parametrization are different robotics capabilities. Treating parametrization as a spec role keeps `ManipulationModule` as orchestration and allows simple and serious backends to share one contract. + +Alternative considered: keep time generation inside `ManipulationModule.execute_plan()`. This preserves the current implementation shape but leaves timing invisible to preview, benchmarking, validation, and failure handling. + +### GeneratedTrajectory is global and canonical + +`GeneratedTrajectory` should represent the canonical global timed joint trajectory for the selected planning groups. It should not own coordinator-task-specific projections as canonical data. + +Rationale: a generated trajectory is still a manipulation-planning artifact. Keeping it global preserves the semantics of the source `GeneratedPlan`, especially for composite and multi-robot paths with a shared time domain. It also avoids coupling parametrization backends to current coordinator task wiring. + +Alternative considered: store robot-local or task-local projections directly inside `GeneratedTrajectory`. This is convenient for execution but makes the artifact dependent on dispatch topology and invites independent per-task timing. + +### TrajectoryDispatch as a separate execution-preparation boundary + +Add `TrajectoryDispatch` as the artifact that maps a `GeneratedTrajectory` into the per-task or per-robot `JointTrajectory` messages required by `ControlCoordinator` tasks. + +Rationale: dispatch is not trajectory parametrization. Dispatch knows about current task names, joint ownership, and coordinator invocation details. Separating it lets `TrajectoryParametrizerSpec` stay independent of control-task wiring while keeping execution preparation observable and testable. + +Alternative considered: let each parametrizer emit coordinator task messages. This would make every backend depend on control task layout and would duplicate dispatch rules. + +### Shared trajectory time domain for composite motion + +A `GeneratedTrajectory` produced from a composite or multi-robot `GeneratedPlan` must preserve one shared time domain across all selected joints and robot-local projections derived during dispatch. + +Rationale: coupled planning can be invalidated if each robot is retimed independently. A shared time basis allows preview, validation, benchmarking, and execution to refer to the same coordinated motion. + +Alternative considered: independently retime each robot-local path and start all tasks together. This is simpler but breaks the semantics of coordinated composite motion and can produce relative timing changes the planner did not validate. + +### Preview, validation, benchmarking, and execution share the same artifact + +Preview, validation, benchmarking, and execution dispatch should all consume the same `GeneratedTrajectory`, not independently assign duration or timing to the geometric `GeneratedPlan`. + +Rationale: the motion the user previews, the benchmark measures, and the robot executes should be the same time-parametrized artifact. This removes the current mismatch where preview can use a fixed display duration while execution uses generated timing. + +Alternative considered: keep preview as display-only interpolation over the geometric path. This is acceptable for rough visualization but not for validating timing-sensitive behavior. + +### simple_trapezoid default and TOPPRA opt-in + +Keep `simple_trapezoid` as the initial default backend and support TOPPRA as an explicit opt-in backend. + +Rationale: this separates architecture risk from backend integration risk. The new spec/artifact boundary can land with current behavior preserved, while TOPPRA becomes available for serious parametrization and future validation before flipping defaults. + +Alternative considered: make TOPPRA the default immediately. This may be the right future direction, but it should follow validation and benchmark evidence. + +### TOPPRA install and test policy + +Add a `manipulation-toppra` optional extra containing the PyPI `toppra` package, include that extra in `all`, and require the repository test environment to run TOPPRA tests unconditionally. + +Rationale: runtime users should not need TOPPRA unless they configure it, but DimOS developers and CI should continuously validate the supported TOPPRA backend. Optional runtime dependency should not mean skipped repository coverage. + +Alternative considered: skip TOPPRA tests when import is unavailable. This hides regressions in a supported backend and contradicts the decision to include TOPPRA in the repo test surface. + +### Missing configured TOPPRA dependency fails initialization + +If configuration selects `backend="toppra"` and the dependency is unavailable, planning/parametrization initialization should fail clearly with guidance to install `dimos[manipulation-toppra]`. + +Rationale: an explicitly configured missing backend is an environment/configuration error, not a motion-planning outcome. Failing early avoids discovering the problem only when a motion is attempted. + +Alternative considered: return `GeneratedTrajectory.status = BACKEND_UNAVAILABLE` during parametrization. This is useful only for dynamic backend unavailability, not predictable missing imports from explicit configuration. + +### Separate status layers + +Keep planning, parametrization, dispatch, and execution statuses separate. A parametrization failure must not overwrite `GeneratedPlan.status`. + +Rationale: users and tests need to distinguish “planner failed to find a path,” “planner found a path but constraints made timing infeasible,” and “trajectory succeeded but dispatch or execution failed.” + +Alternative considered: use one combined status on the latest artifact. This loses diagnostic information and makes recovery decisions ambiguous. + +## Risks / Trade-offs + +- [Risk] TOPPRA's Python package exists on PyPI but upstream documentation signals possible future migration away from Python support. → Mitigation: keep the backend behind a spec boundary and optional extra so another implementation can replace it without changing orchestration. +- [Risk] Adding `GeneratedTrajectory` and `TrajectoryDispatch` increases artifact count. → Mitigation: the artifacts correspond to real pipeline boundaries: geometric planning, time parametrization, and execution preparation. +- [Risk] The `simple_trapezoid` backend may preserve current stop-at-waypoint behavior. → Mitigation: treat it as a compatibility fallback, not the final quality target. +- [Risk] Benchmark comparisons can be misleading if TOPPRA gridpoint/discretization policy varies. → Mitigation: make gridpoint/discretization policy explicit in config and tests. +- [Risk] Dispatch logic can drift from parametrization assumptions. → Mitigation: test that dispatch preserves the generated trajectory's global timing and joint ordering. + +## Migration Plan + +1. Add the new config, model, status, and Protocol definitions without changing planner algorithms. +2. Wrap current trapezoid timing behavior behind `simple_trapezoid` as the default parametrizer. +3. Change manipulation orchestration so planning produces `GeneratedPlan`, parametrization produces `GeneratedTrajectory`, and dispatch produces task-specific `JointTrajectory` messages. +4. Update preview paths to consume `GeneratedTrajectory` timing rather than an ad hoc duration. +5. Add TOPPRA backend support and dependency extra, then include it in `all` and the repo test environment. +6. Add tests for artifact contracts, backend status behavior, shared time domain, TOPPRA behavior, and dispatch preservation. +7. Rollback is restoring direct execution-time generation in `ManipulationModule` and removing the new spec/models/backends; geometric planner APIs can remain unchanged. + +## Open Questions + +- What validation or benchmark threshold should justify making TOPPRA the default backend later? +- Should future parametrizers support duration targeting or only constraint-satisfying minimum-time behavior? +- Which backend, if any, should introduce jerk-limited or S-curve profiles? diff --git a/openspec/changes/add-manipulation-trajectory-parametrization-spec/proposal.md b/openspec/changes/add-manipulation-trajectory-parametrization-spec/proposal.md new file mode 100644 index 0000000000..1d96ca00c2 --- /dev/null +++ b/openspec/changes/add-manipulation-trajectory-parametrization-spec/proposal.md @@ -0,0 +1,35 @@ +## Why + +Manipulation planning currently produces a geometric `GeneratedPlan`, then `ManipulationModule.execute_plan()` assigns timing as an execution-time implementation detail using the current joint trajectory generator. Preview, execution, and future benchmarking can therefore use different temporal assumptions, and composite or multi-robot plans can be split into independently timed per-robot trajectories even when the geometric plan was coordinated. + +DimOS needs trajectory parametrization as a first-class manipulation-planning capability: a planner produces a geometric path, a parametrizer assigns time under motion constraints, and orchestration dispatches the resulting trajectory to control tasks without conflating planning, parametrization, and execution status. + +## What Changes + +- Add a `TrajectoryParametrizerSpec` role to the manipulation multi-spec architecture. +- Add `GeneratedTrajectory` as the canonical global time-parametrized manipulation artifact produced from a `GeneratedPlan`. +- Add `TrajectoryDispatch` as the execution-preparation artifact that derives task-specific `JointTrajectory` messages from a `GeneratedTrajectory`. +- Preserve a shared trajectory time domain for composite and multi-robot motions. +- Make preview, validation, benchmarking, and execution dispatch consume the same `GeneratedTrajectory` artifact. +- Provide a default `simple_trapezoid` backend that wraps the current timing behavior behind the new spec boundary. +- Add optional TOPPRA support through a `manipulation-toppra` extra and include that extra in `all` and the repository test environment. +- Keep TOPPRA explicit opt-in initially; do not make it the default backend until validation and benchmarks justify the switch. + +## Capabilities + +### New Capabilities +- `manipulation-trajectory-parametrization`: First-class trajectory parametrization, generated trajectory artifacts, dispatch preparation, and backend policy for manipulation planning. + +### Modified Capabilities + +## Impact + +- Affected code areas: + - `dimos/manipulation/planning/spec/` for the new spec role, models, config, and status types. + - `dimos/manipulation/planning/trajectory_generator/` or successor backend package for the `simple_trapezoid` and TOPPRA parametrizers. + - `dimos/manipulation/manipulation_module.py` for orchestration, preview, parametrization, and dispatch flow. + - `dimos/msgs/trajectory_msgs/` only as the low-level dispatch message boundary, not as the canonical manipulation artifact. + - `pyproject.toml` for the `manipulation-toppra` optional extra and inclusion in `all`. + - manipulation unit/integration tests for artifact contracts, backend behavior, preview/execution sharing, and TOPPRA execution in the repo test environment. +- Existing geometric planning APIs should remain conceptually geometric. Timing belongs to the parametrization step. +- `ControlCoordinator` task internals are out of scope; dispatch prepares existing task messages. diff --git a/openspec/changes/add-manipulation-trajectory-parametrization-spec/specs/manipulation-trajectory-parametrization/spec.md b/openspec/changes/add-manipulation-trajectory-parametrization-spec/specs/manipulation-trajectory-parametrization/spec.md new file mode 100644 index 0000000000..f3b0dd3b77 --- /dev/null +++ b/openspec/changes/add-manipulation-trajectory-parametrization-spec/specs/manipulation-trajectory-parametrization/spec.md @@ -0,0 +1,97 @@ +## ADDED Requirements + +### Requirement: First-class trajectory parametrization spec +The system SHALL provide a `TrajectoryParametrizerSpec` manipulation-planning role that converts a successful geometric `GeneratedPlan` into a time-parametrized `GeneratedTrajectory`. + +#### Scenario: Parametrizer consumes geometric plan +- **WHEN** geometric planning succeeds and produces a `GeneratedPlan` +- **THEN** trajectory parametrization MUST consume the geometric path without overwriting the planning result + +#### Scenario: Parametrizer remains independent of coordinator wiring +- **WHEN** a trajectory parametrization backend is implemented +- **THEN** it MUST NOT depend on `ControlCoordinator` task names, task instances, or task-specific dispatch wiring + +### Requirement: Generated trajectory artifact +The system SHALL define `GeneratedTrajectory` as the canonical global time-parametrized manipulation artifact produced from a `GeneratedPlan`. + +#### Scenario: Generated trajectory is global +- **WHEN** a `GeneratedTrajectory` is produced +- **THEN** it MUST represent the global selected joint trajectory rather than task-specific or robot-local command messages + +#### Scenario: Generated trajectory has explicit status +- **WHEN** trajectory parametrization succeeds or fails +- **THEN** `GeneratedTrajectory` MUST report an explicit parametrization status and message without changing `GeneratedPlan.status` + +### Requirement: Shared trajectory time domain +A `GeneratedTrajectory` produced from a composite or multi-robot `GeneratedPlan` SHALL preserve one shared time domain across all selected joints. + +#### Scenario: Composite trajectory is parametrized +- **WHEN** a composite or multi-robot `GeneratedPlan` is parametrized +- **THEN** all selected joints in the resulting `GeneratedTrajectory` MUST use the same timing basis + +#### Scenario: Dispatch derives local messages +- **WHEN** task-specific or robot-local command messages are derived from a composite `GeneratedTrajectory` +- **THEN** those messages MUST preserve the generated trajectory's shared timing basis + +### Requirement: Trajectory dispatch boundary +The system SHALL define a `TrajectoryDispatch` execution-preparation artifact that derives control-task-specific `JointTrajectory` messages from a `GeneratedTrajectory`. + +#### Scenario: Dispatch prepares coordinator messages +- **WHEN** execution is requested for a successful `GeneratedTrajectory` +- **THEN** manipulation orchestration MUST derive the required task-specific `JointTrajectory` messages through dispatch before invoking control tasks + +#### Scenario: Dispatch is separate from parametrization +- **WHEN** a `GeneratedTrajectory` is produced +- **THEN** execution-specific projections MUST be produced by a separate dispatch/preparation step rather than stored as canonical generated trajectory data + +### Requirement: Shared generated trajectory for preview, validation, benchmarking, and execution +Preview, validation, benchmarking, and execution dispatch SHALL consume the same `GeneratedTrajectory` artifact for a planned manipulation motion. + +#### Scenario: Preview uses generated trajectory timing +- **WHEN** a planned motion is previewed +- **THEN** preview MUST use the timing from `GeneratedTrajectory` rather than assigning an independent preview duration to the geometric `GeneratedPlan` + +#### Scenario: Execution dispatch uses generated trajectory timing +- **WHEN** a planned motion is executed +- **THEN** execution dispatch MUST consume the same `GeneratedTrajectory` artifact used by preview, validation, or benchmarking for that motion + +### Requirement: Parametrization backend policy +The system SHALL support backend-configurable trajectory parametrization policy with a default `simple_trapezoid` backend and an explicit opt-in TOPPRA backend. + +#### Scenario: Default backend is available +- **WHEN** no trajectory parametrization backend is explicitly configured +- **THEN** the system MUST use a `simple_trapezoid` backend that preserves current baseline timing behavior behind the new spec boundary + +#### Scenario: TOPPRA backend is configured and installed +- **WHEN** `backend="toppra"` is configured and TOPPRA is available +- **THEN** the system MUST use TOPPRA for joint velocity and acceleration constrained trajectory parametrization + +#### Scenario: TOPPRA backend is configured but missing +- **WHEN** `backend="toppra"` is configured and TOPPRA cannot be imported during planning or parametrization initialization +- **THEN** initialization MUST fail clearly with guidance to install TOPPRA support through `dimos[manipulation-toppra]` + +#### Scenario: TOPPRA policy is reproducible +- **WHEN** TOPPRA parametrization is configured for tests or benchmarks +- **THEN** gridpoint or discretization policy MUST be explicit enough for reproducible comparisons + +### Requirement: TOPPRA packaging and repository tests +The system SHALL expose TOPPRA support through a `manipulation-toppra` optional extra, include that extra in `all`, and run TOPPRA parametrization tests unconditionally in the repository test environment. + +#### Scenario: Runtime user omits TOPPRA extra +- **WHEN** a runtime installation omits `manipulation-toppra` and does not configure `backend="toppra"` +- **THEN** baseline manipulation trajectory parametrization MUST remain available through `simple_trapezoid` + +#### Scenario: Repository tests run +- **WHEN** the repository test environment runs trajectory parametrization tests +- **THEN** TOPPRA tests MUST run without import-based skipping + +### Requirement: Separate planning, parametrization, dispatch, and execution statuses +The system SHALL distinguish geometric planning status, trajectory parametrization status, dispatch preparation status, and runtime execution status. + +#### Scenario: Parametrization is infeasible after planning succeeds +- **WHEN** geometric planning succeeds but trajectory constraints are infeasible +- **THEN** `GeneratedPlan.status` MUST remain successful and `GeneratedTrajectory.status` MUST report parametrization infeasibility + +#### Scenario: Dispatch fails after parametrization succeeds +- **WHEN** trajectory parametrization succeeds but coordinator task dispatch fails +- **THEN** `GeneratedTrajectory.status` MUST remain successful and dispatch or execution status MUST report the dispatch failure diff --git a/openspec/changes/add-manipulation-trajectory-parametrization-spec/tasks.md b/openspec/changes/add-manipulation-trajectory-parametrization-spec/tasks.md new file mode 100644 index 0000000000..b73b038913 --- /dev/null +++ b/openspec/changes/add-manipulation-trajectory-parametrization-spec/tasks.md @@ -0,0 +1,40 @@ +## 1. Spec, Config, and Models + +- [ ] 1.1 Add trajectory parametrization config with backend selection, velocity/acceleration scales, optional velocity/acceleration limits, optional minimum segment duration, and explicit TOPPRA gridpoint/discretization policy. +- [ ] 1.2 Add `TrajectoryParametrizerSpec` as a DimOS Spec Protocol that converts a successful `GeneratedPlan` into a `GeneratedTrajectory`. +- [ ] 1.3 Add `GeneratedTrajectory` as the canonical global timed manipulation artifact with joint names, timed points, shared duration/time domain, status, message, and source planning metadata. +- [ ] 1.4 Add `TrajectoryDispatch` as the execution-preparation artifact that contains task-specific `JointTrajectory` messages and dispatch status/message without changing `GeneratedTrajectory` timing. +- [ ] 1.5 Add explicit parametrization and dispatch status values that do not overwrite `GeneratedPlan.status`. + +## 2. Parametrization Backends + +- [ ] 2.1 Wrap the current trapezoidal timing logic behind a `simple_trapezoid` trajectory parametrizer backend. +- [ ] 2.2 Preserve current default behavior by selecting `simple_trapezoid` unless another backend is configured. +- [ ] 2.3 Add a TOPPRA backend using the PyPI `toppra` Python API for joint velocity and acceleration constrained path parametrization. +- [ ] 2.4 Fail planning/parametrization initialization clearly when `backend="toppra"` is configured but TOPPRA cannot be imported, including guidance to install `dimos[manipulation-toppra]`. +- [ ] 2.5 Ensure TOPPRA policy exposes gridpoint/discretization behavior explicitly enough for reproducible tests and benchmark comparisons. + +## 3. Orchestration and Dispatch + +- [ ] 3.1 Update manipulation planning flow so geometric planning stores `GeneratedPlan` and a separate parametrization step stores `GeneratedTrajectory`. +- [ ] 3.2 Update preview, validation, and benchmark-facing paths to consume `GeneratedTrajectory` instead of independently timing `GeneratedPlan`. +- [ ] 3.3 Add dispatch orchestration in `ManipulationModule` that maps global `GeneratedTrajectory` into coordinator task-specific `JointTrajectory` messages. +- [ ] 3.4 Ensure `TrajectoryParametrizerSpec` has no dependency on `ControlCoordinator` task names or task wiring. +- [ ] 3.5 Preserve one shared trajectory time domain across composite and multi-robot generated trajectories and their dispatch projections. + +## 4. Packaging + +- [ ] 4.1 Add `manipulation-toppra = ["toppra>=0.6.8"]` or an equivalent current TOPPRA dependency constraint to optional extras. +- [ ] 4.2 Include `manipulation-toppra` in the `all` extra. +- [ ] 4.3 Ensure the repository test environment installs TOPPRA support so TOPPRA tests run unconditionally. + +## 5. Tests and Validation + +- [ ] 5.1 Add contract tests showing `GeneratedPlan` remains geometric and `GeneratedTrajectory` carries the time-parametrized global path. +- [ ] 5.2 Add tests that parametrization failure leaves `GeneratedPlan.status` unchanged and reports failure on `GeneratedTrajectory`. +- [ ] 5.3 Add `simple_trapezoid` tests for joint ordering, monotonic `time_from_start`, final waypoint preservation, and shared time domain behavior. +- [ ] 5.4 Add TOPPRA tests that run unconditionally in the repo test environment and cover velocity/acceleration constraints plus explicit gridpoint/discretization policy. +- [ ] 5.5 Add dispatch tests proving task-specific `JointTrajectory` messages preserve global generated trajectory timing and joint ordering. +- [ ] 5.6 Add orchestration tests proving preview and execution dispatch consume the same `GeneratedTrajectory` artifact. +- [ ] 5.7 Run focused manipulation trajectory parametrization tests. +- [ ] 5.8 Run OpenSpec validation for this change and fix proposal, spec, design, or task formatting issues. From 4f72a1dee9fd271e6c612b8993c9c04ae11fb428 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 27 Jun 2026 08:31:06 -0700 Subject: [PATCH 093/110] feat: add libero-pro runtime sidecar --- .gitignore | 6 +- dimos/benchmark/runtime/config.py | 49 +- .../configs/libero_pro_goal_task0.json | 27 + dimos/benchmark/runtime/test_config.py | 87 ++ .../runtime/test_libero_pro_runtime_manual.py | 58 ++ .../test_libero_pro_sidecar_profile.py | 262 ++++++ .../runtime/test_sidecar_import_boundaries.py | 23 + docs/development/runtime_sidecars.md | 78 ++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../benchmark-prelaunch-orchestration/spec.md | 0 .../specs/libero-pro-runtime-sidecar/spec.md | 0 .../specs/scripted-runtime-demos/spec.md | 0 .../tasks.md | 66 +- .../benchmark-prelaunch-orchestration/spec.md | 28 +- .../specs/libero-pro-runtime-sidecar/spec.md | 86 ++ openspec/specs/scripted-runtime-demos/spec.md | 34 + .../dimos-libero-pro-sidecar/pyproject.toml | 22 + .../src/dimos_libero_pro_sidecar/__init__.py | 18 + .../src/dimos_libero_pro_sidecar/assets.py | 56 ++ .../src/dimos_libero_pro_sidecar/server.py | 680 +++++++++++++++ .../src/dimos_runtime_protocol/models.py | 21 +- scripts/benchmarks/demo_libero_pro_runtime.py | 787 ++++++++++++++++++ 24 files changed, 2345 insertions(+), 43 deletions(-) create mode 100644 dimos/benchmark/runtime/configs/libero_pro_goal_task0.json create mode 100644 dimos/benchmark/runtime/test_libero_pro_runtime_manual.py create mode 100644 dimos/benchmark/runtime/test_libero_pro_sidecar_profile.py rename openspec/changes/{libero-pro-runtime-sidecar => archive/2026-06-27-libero-pro-runtime-sidecar}/.openspec.yaml (100%) rename openspec/changes/{libero-pro-runtime-sidecar => archive/2026-06-27-libero-pro-runtime-sidecar}/design.md (100%) rename openspec/changes/{libero-pro-runtime-sidecar => archive/2026-06-27-libero-pro-runtime-sidecar}/proposal.md (100%) rename openspec/changes/{libero-pro-runtime-sidecar => archive/2026-06-27-libero-pro-runtime-sidecar}/specs/benchmark-prelaunch-orchestration/spec.md (100%) rename openspec/changes/{libero-pro-runtime-sidecar => archive/2026-06-27-libero-pro-runtime-sidecar}/specs/libero-pro-runtime-sidecar/spec.md (100%) rename openspec/changes/{libero-pro-runtime-sidecar => archive/2026-06-27-libero-pro-runtime-sidecar}/specs/scripted-runtime-demos/spec.md (100%) rename openspec/changes/{libero-pro-runtime-sidecar => archive/2026-06-27-libero-pro-runtime-sidecar}/tasks.md (56%) create mode 100644 openspec/specs/libero-pro-runtime-sidecar/spec.md create mode 100644 packages/dimos-libero-pro-sidecar/pyproject.toml create mode 100644 packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/__init__.py create mode 100644 packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/assets.py create mode 100644 packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/server.py create mode 100644 scripts/benchmarks/demo_libero_pro_runtime.py diff --git a/.gitignore b/.gitignore index 832f136c7c..a293132ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -100,11 +100,11 @@ recording*.db /misc/fresh-ubuntu-tests/cache -# openspec -/openspec/changes/archive/ - # Deepwork local progress state .slim/deepwork/ # Benchmark demo output artifacts artifacts/ + +# LIBERO-PRO downloaded benchmark assets +/libero/ diff --git a/dimos/benchmark/runtime/config.py b/dimos/benchmark/runtime/config.py index ca826831a2..779a81dc6a 100644 --- a/dimos/benchmark/runtime/config.py +++ b/dimos/benchmark/runtime/config.py @@ -17,9 +17,11 @@ from __future__ import annotations from pathlib import Path +from typing import Literal -from dimos_runtime_protocol import RuntimeDescription, check_compatible -from pydantic import BaseModel, ConfigDict, PositiveInt +from dimos_runtime_protocol import CommandMode, RuntimeDescription, check_compatible +from dimos_runtime_protocol.types import JsonObject +from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, PositiveInt, model_validator class StrictModel(BaseModel): @@ -47,6 +49,46 @@ class BenchmarkEpisodeConfig(StrictModel): horizon: PositiveInt = 200 camera_name: str = "agentview" visualize: bool = False + backend_options: JsonObject = Field(default_factory=dict) + + +class LiberoProBackendOptions(StrictModel): + """Typed backend-specific options for registered LIBERO-PRO tasks.""" + + benchmark_name: str + task_order_index: NonNegativeInt = 0 + task_index: NonNegativeInt = 0 + init_state_index: NonNegativeInt = 0 + controller: str = "JOINT_POSITION" + camera_names: list[str] = Field(default_factory=lambda: ["agentview"], min_length=1) + horizon: PositiveInt = 1000 + bddl_root: Path + init_states_root: Path + allow_asset_bootstrap: bool = False + perturbation_mode: Literal["registered"] = "registered" + + @model_validator(mode="after") + def _validate_non_empty_names(self) -> LiberoProBackendOptions: + if not self.benchmark_name.strip(): + raise ValueError("benchmark_name must be non-empty") + if not self.controller.strip(): + raise ValueError("controller must be non-empty") + empty_cameras = [ + camera_name for camera_name in self.camera_names if not camera_name.strip() + ] + if empty_cameras: + raise ValueError("camera_names must not contain empty names") + return self + + +def validate_libero_pro_backend_options(config: BenchmarkEpisodeConfig) -> LiberoProBackendOptions: + """Validate and return typed LIBERO-PRO backend options for an episode config.""" + + if config.backend != "libero-pro": + raise ValueError( + "LIBERO-PRO backend options can only be validated for backend 'libero-pro'" + ) + return LiberoProBackendOptions.model_validate(config.backend_options) class ResolvedRuntimePlan(StrictModel): @@ -85,6 +127,9 @@ def resolve_runtime_plan( motor_names = [motor.name for motor in sorted(surface.motors, key=lambda motor: motor.index)] if len(motor_names) != config.dof: raise ValueError(f"expected {config.dof} motors, sidecar reported {len(motor_names)}") + if CommandMode.POSITION not in surface.supported_command_modes: + supported = ", ".join(mode.value for mode in surface.supported_command_modes) + raise ValueError(f"sidecar robot surface does not support position commands: {supported}") return ResolvedRuntimePlan( episode_id=config.episode_id, task_id=config.task_id, diff --git a/dimos/benchmark/runtime/configs/libero_pro_goal_task0.json b/dimos/benchmark/runtime/configs/libero_pro_goal_task0.json new file mode 100644 index 0000000000..cfa7ec88ac --- /dev/null +++ b/dimos/benchmark/runtime/configs/libero_pro_goal_task0.json @@ -0,0 +1,27 @@ +{ + "backend": "libero-pro", + "episode_id": "libero-pro-goal-task0", + "task_id": "libero_goal_task/0", + "runtime_host": "127.0.0.1", + "runtime_port": 8767, + "robot_id": "panda", + "dof": 8, + "control_step_hz": 20, + "ticks": 200, + "target_position": 0.03, + "seed": 7, + "artifact_dir": "artifacts/benchmark/libero-pro-goal-task0", + "backend_options": { + "benchmark_name": "libero_goal_task", + "task_order_index": 0, + "task_index": 0, + "init_state_index": 0, + "controller": "JOINT_POSITION", + "camera_names": [ + "agentview" + ], + "horizon": 1000, + "bddl_root": "libero/libero/bddl_files", + "init_states_root": "libero/libero/init_files" + } +} diff --git a/dimos/benchmark/runtime/test_config.py b/dimos/benchmark/runtime/test_config.py index 1548c59006..623a5dab62 100644 --- a/dimos/benchmark/runtime/test_config.py +++ b/dimos/benchmark/runtime/test_config.py @@ -26,6 +26,7 @@ sys.path.insert(0, str(PROTOCOL_SRC)) from dimos_runtime_protocol import ( + CommandMode, MotorDescription, ProtocolVersion, RobotMotorSurface, @@ -34,9 +35,13 @@ from dimos.benchmark.runtime.config import ( BenchmarkEpisodeConfig, + LiberoProBackendOptions, resolve_runtime_plan, + validate_libero_pro_backend_options, ) +CONFIG_DIR = Path(__file__).parent / "configs" + def test_resolve_runtime_plan_rejects_incompatible_protocol() -> None: description = _description(protocol=ProtocolVersion(version="1.0", min_compatible="1.0")) @@ -52,10 +57,91 @@ def test_resolve_runtime_plan_rejects_robot_profile_mismatch() -> None: resolve_runtime_plan(BenchmarkEpisodeConfig(), description) +def test_resolve_runtime_plan_rejects_missing_position_command_mode() -> None: + description = _description(command_modes=[CommandMode.VELOCITY]) + + with pytest.raises(ValueError, match="does not support position commands"): + resolve_runtime_plan(BenchmarkEpisodeConfig(), description) + + +def test_existing_configs_still_parse_after_backend_options_added() -> None: + fake = BenchmarkEpisodeConfig.model_validate_json( + (CONFIG_DIR / "fake_runtime_smoke.json").read_text() + ) + robosuite = BenchmarkEpisodeConfig.model_validate_json( + (CONFIG_DIR / "robosuite_panda_lift.json").read_text() + ) + + assert fake.backend_options == {} + assert robosuite.backend_options == {} + + +def test_libero_pro_config_parses_typed_backend_options() -> None: + config = BenchmarkEpisodeConfig.model_validate_json( + (CONFIG_DIR / "libero_pro_goal_task0.json").read_text() + ) + + options = validate_libero_pro_backend_options(config) + + assert config.backend == "libero-pro" + assert options == LiberoProBackendOptions( + benchmark_name="libero_goal_task", + task_order_index=0, + task_index=0, + init_state_index=0, + controller="JOINT_POSITION", + camera_names=["agentview"], + horizon=1000, + bddl_root=Path("libero/libero/bddl_files"), + init_states_root=Path("libero/libero/init_files"), + ) + + +def test_libero_pro_options_reject_missing_required_assets() -> None: + config = BenchmarkEpisodeConfig( + backend="libero-pro", + robot_id="panda", + dof=8, + backend_options={ + "benchmark_name": "libero_goal_task", + "task_index": 0, + "init_state_index": 0, + "controller": "JOINT_POSITION", + }, + ) + + with pytest.raises(ValueError, match="bddl_root"): + validate_libero_pro_backend_options(config) + + +def test_libero_pro_options_reject_dynamic_perturbation_mode() -> None: + config = BenchmarkEpisodeConfig( + backend="libero-pro", + robot_id="panda", + dof=8, + backend_options={ + "benchmark_name": "libero_goal_task", + "task_order_index": 0, + "task_index": 0, + "init_state_index": 0, + "controller": "JOINT_POSITION", + "camera_names": ["agentview"], + "horizon": 1000, + "bddl_root": "libero/libero/bddl_files", + "init_states_root": "libero/libero/init_files", + "perturbation_mode": "dynamic", + }, + ) + + with pytest.raises(ValueError, match="perturbation_mode"): + validate_libero_pro_backend_options(config) + + def _description( *, robot_id: str = "fakebot", protocol: ProtocolVersion | None = None, + command_modes: list[CommandMode] | None = None, ) -> RuntimeDescription: return RuntimeDescription( runtime_id="fake-runtime", @@ -67,6 +153,7 @@ def _description( motors=[ MotorDescription(name=f"{robot_id}/joint{i + 1}", index=i) for i in range(3) ], + supported_command_modes=command_modes or [CommandMode.POSITION], ) ], control_step_hz=100, diff --git a/dimos/benchmark/runtime/test_libero_pro_runtime_manual.py b/dimos/benchmark/runtime/test_libero_pro_runtime_manual.py new file mode 100644 index 0000000000..7f5bd87042 --- /dev/null +++ b/dimos/benchmark/runtime/test_libero_pro_runtime_manual.py @@ -0,0 +1,58 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Optional real LIBERO-PRO runtime demo coverage.""" + +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +import sys +from types import ModuleType +from typing import Protocol, cast + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +DEMO_SCRIPT = REPO_ROOT / "scripts" / "benchmarks" / "demo_libero_pro_runtime.py" +CONFIG_PATH = ( + REPO_ROOT / "dimos" / "benchmark" / "runtime" / "configs" / "libero_pro_goal_task0.json" +) + +_DEMO_SPEC = importlib.util.spec_from_file_location("demo_libero_pro_runtime", DEMO_SCRIPT) +assert _DEMO_SPEC is not None +assert _DEMO_SPEC.loader is not None +_DEMO_MODULE = importlib.util.module_from_spec(_DEMO_SPEC) +sys.modules[_DEMO_SPEC.name] = _DEMO_MODULE +_DEMO_SPEC.loader.exec_module(_DEMO_MODULE) + + +class _RunDemo(Protocol): + def __call__(self, config_path: Path) -> Path: ... + + +run_demo = cast("_RunDemo", cast("ModuleType", _DEMO_MODULE).__dict__["run_demo"]) + + +@pytest.mark.self_hosted_large +def test_real_libero_pro_runtime_demo_with_prepared_assets() -> None: + if os.environ.get("DIMOS_RUN_LIBERO_PRO_MANUAL_TEST") != "1": + pytest.skip("set DIMOS_RUN_LIBERO_PRO_MANUAL_TEST=1 in a prepared LIBERO-PRO env") + + artifact_dir = run_demo(CONFIG_PATH) + + assert (artifact_dir / "score.json").exists() + assert (artifact_dir / "protocol_trace_summary.json").exists() + assert (artifact_dir / "motor_trace.json").exists() diff --git a/dimos/benchmark/runtime/test_libero_pro_sidecar_profile.py b/dimos/benchmark/runtime/test_libero_pro_sidecar_profile.py new file mode 100644 index 0000000000..c63572a75b --- /dev/null +++ b/dimos/benchmark/runtime/test_libero_pro_sidecar_profile.py @@ -0,0 +1,262 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Stubbed profile tests for the LIBERO-PRO runtime sidecar.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from io import BytesIO +import json +from pathlib import Path +import sys +from threading import Thread +from typing import cast +from urllib.request import Request, urlopen + +import numpy as np +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +LIBERO_PRO_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-libero-pro-sidecar" / "src" +sys.path.insert(0, str(PROTOCOL_SRC)) +sys.path.insert(0, str(LIBERO_PRO_SIDECAR_SRC)) + +from dimos_libero_pro_sidecar.server import ( + LiberoProRuntimeConfig, + LiberoProRuntimeState, + make_server, + validate_assets, +) +from dimos_runtime_protocol import EpisodeResetRequest, MotorActionFrame, StepRequest + + +class _FakeLiberoBackend: + action_low = [-1.0] * 8 + action_high = [1.0] * 8 + task_name = "pick_up_the_black_bowl" + language = "pick up the black bowl" + + def reset(self, init_state_index: int) -> dict[str, object]: + return _fake_obs([0.0] * 7, [0.0]) | {"init_state_index": init_state_index} + + def step( + self, action: Sequence[float] + ) -> tuple[dict[str, object], float, bool, dict[str, object]]: + values = [float(item) for item in action] + return _fake_obs(values[:7], [values[7]]), 0.75, False, {"success": True} + + def render(self) -> None: + return + + +class _RenderCountingBackend(_FakeLiberoBackend): + def __init__(self) -> None: + self.render_count = 0 + + def render(self) -> None: + self.render_count += 1 + + +class _BadActionBackend(_FakeLiberoBackend): + action_low = [-1.0] * 7 + action_high = [1.0] * 7 + + +def test_libero_pro_profile_maps_actions_states_score_and_payloads(tmp_path: Path) -> None: + state = LiberoProRuntimeState(_config(tmp_path), backend=_FakeLiberoBackend()) + + description = state.describe() + assert description.backend == "libero-pro" + assert [motor.name for motor in description.robot_surfaces[0].motors] == [ + "panda/joint1", + "panda/joint2", + "panda/joint3", + "panda/joint4", + "panda/joint5", + "panda/joint6", + "panda/joint7", + "panda/gripper", + ] + assert description.metadata["benchmark_name"] == "libero_pro" + + reset = state.reset(EpisodeResetRequest(episode_id="episode", task_id="task")) + assert {frame.stream for frame in reset.observations} == {"robot_state", "agentview"} + + response = state.step( + StepRequest( + episode_id="episode", + tick_id=1, + action=MotorActionFrame(robot_id="panda", names=state.motor_names, q=[0.1] * 8), + ) + ) + + assert response.success is True + assert response.motor_state.q == [0.1] * 8 + image_frame = next(frame for frame in response.observations if frame.stream == "agentview") + assert image_frame.data_ref is not None + assert image_frame.metadata["image_convention"] == "opengl" + assert image_frame.metadata["camera_source"] == "libero_pro_observation" + payload = state.payload_bytes(image_frame.data_ref.removeprefix("/payloads/")) + assert np.array_equal(np.load(BytesIO(payload), allow_pickle=False), _pure_color_image()) + + score = state.score() + assert score.success is True + assert score.metrics["steps"] == 1 + assert score.metrics["task_name"] == "pick_up_the_black_bowl" + assert score.metrics["language"] == "pick up the black bowl" + + +def test_libero_pro_rejects_incompatible_action_dimension(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="action_dim=7"): + LiberoProRuntimeState(_config(tmp_path), backend=_BadActionBackend()) + + +def test_libero_pro_rejects_unsupported_controller(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="unsupported LIBERO-PRO controller"): + LiberoProRuntimeState( + _config(tmp_path, controller="OSC_POSE"), backend=_FakeLiberoBackend() + ) + + +def test_libero_pro_visual_mode_renders_after_reset_and_step(tmp_path: Path) -> None: + backend = _RenderCountingBackend() + state = LiberoProRuntimeState(_config(tmp_path, visualize=True), backend=backend) + + state.reset(EpisodeResetRequest(episode_id="episode", task_id="task")) + state.step( + StepRequest( + episode_id="episode", + tick_id=1, + action=MotorActionFrame(robot_id="panda", names=state.motor_names, q=[0.1] * 8), + ) + ) + + assert backend.render_count == 2 + + +def test_libero_pro_asset_validation_does_not_bootstrap_by_default(tmp_path: Path) -> None: + config = LiberoProRuntimeConfig( + host="127.0.0.1", + port=8767, + benchmark_name="libero_pro", + bddl_root=tmp_path / "missing-bddl", + init_states_root=tmp_path / "missing-init", + ) + + with pytest.raises(FileNotFoundError, match="BDDL root"): + validate_assets(config) + + +def test_libero_pro_http_endpoints_with_stubbed_backend(tmp_path: Path) -> None: + config = _config(tmp_path) + state = LiberoProRuntimeState(config, backend=_FakeLiberoBackend()) + server = make_server( + LiberoProRuntimeConfig( + host="127.0.0.1", + port=0, + benchmark_name=config.benchmark_name, + bddl_root=config.bddl_root, + init_states_root=config.init_states_root, + camera_names=config.camera_names, + ), + state=state, + ) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + try: + health = _get_json(f"{base_url}/health") + assert health["ok"] is True + assert _get_json(f"{base_url}/describe")["backend"] == "libero-pro" + reset = _post_json(f"{base_url}/reset", {"episode_id": "episode", "task_id": "task"}) + assert reset["episode_id"] == "episode" + step = _post_json( + f"{base_url}/step", + { + "episode_id": "episode", + "tick_id": 1, + "action": {"robot_id": "panda", "names": state.motor_names, "q": [0.2] * 8}, + }, + ) + assert step["success"] is True + observations = cast("Sequence[Mapping[str, object]]", step["observations"]) + image = next(frame for frame in observations if frame["stream"] == "agentview") + payload = urlopen(f"{base_url}{image['data_ref']}", timeout=5).read() + assert np.array_equal(np.load(BytesIO(payload), allow_pickle=False), _pure_color_image()) + assert _get_json(f"{base_url}/score")["success"] is True + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def _config( + tmp_path: Path, *, controller: str = "JOINT_POSITION", visualize: bool = False +) -> LiberoProRuntimeConfig: + bddl_root = tmp_path / "bddl" + init_states_root = tmp_path / "init_states" + bddl_root.mkdir() + init_states_root.mkdir() + (bddl_root / "task.bddl").write_text("fixture") + (init_states_root / "task.pruned_init").write_bytes(b"fixture") + return LiberoProRuntimeConfig( + host="127.0.0.1", + port=8767, + benchmark_name="libero_pro", + bddl_root=bddl_root, + init_states_root=init_states_root, + controller=controller, + camera_names=("agentview",), + init_state_index=2, + visualize=visualize, + ) + + +def _fake_obs(joint_q: list[float], gripper_q: list[float]) -> dict[str, object]: + return { + "robot0_joint_pos": joint_q, + "robot0_joint_vel": [0.0] * len(joint_q), + "robot0_gripper_qpos": gripper_q, + "robot0_gripper_qvel": [0.0] * len(gripper_q), + "agentview_image": _pure_color_image(), + } + + +def _pure_color_image() -> np.ndarray: + image = np.zeros((2, 2, 3), dtype=np.uint8) + image[0, :, :] = [255, 0, 0] + image[1, :, :] = [0, 255, 0] + return image + + +def _get_json(url: str) -> dict[str, object]: + with urlopen(url, timeout=5) as response: + data = json.loads(response.read().decode("utf-8")) + assert isinstance(data, dict) + return data + + +def _post_json(url: str, payload: dict[str, object]) -> dict[str, object]: + request = Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={"content-type": "application/json"}, + method="POST", + ) + with urlopen(request, timeout=5) as response: + data = json.loads(response.read().decode("utf-8")) + assert isinstance(data, dict) + return data diff --git a/dimos/benchmark/runtime/test_sidecar_import_boundaries.py b/dimos/benchmark/runtime/test_sidecar_import_boundaries.py index f7b685fc92..066ef885ae 100644 --- a/dimos/benchmark/runtime/test_sidecar_import_boundaries.py +++ b/dimos/benchmark/runtime/test_sidecar_import_boundaries.py @@ -23,6 +23,7 @@ REPO_ROOT = Path(__file__).resolve().parents[3] PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" ROBOSUITE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src" +LIBERO_PRO_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-libero-pro-sidecar" / "src" def test_robosuite_sidecar_import_does_not_import_heavy_backends_or_dimos() -> None: @@ -45,3 +46,25 @@ def test_robosuite_sidecar_import_does_not_import_heavy_backends_or_dimos() -> N }, ) assert result.returncode == 0, result.stderr or result.stdout + + +def test_libero_pro_sidecar_import_does_not_import_heavy_backends_or_dimos() -> None: + script = """ +import importlib +import sys + +importlib.import_module('dimos_libero_pro_sidecar.server') +for name in ('dimos', 'robosuite', 'libero', 'torch'): + if name in sys.modules: + raise SystemExit(f'unexpected import: {name}') +""" + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + env={ + "PYTHONPATH": f"{PROTOCOL_SRC}:{LIBERO_PRO_SIDECAR_SRC}", + }, + ) + assert result.returncode == 0, result.stderr or result.stdout diff --git a/docs/development/runtime_sidecars.md b/docs/development/runtime_sidecars.md index 506b78a2e2..eba28cecf1 100644 --- a/docs/development/runtime_sidecars.md +++ b/docs/development/runtime_sidecars.md @@ -137,3 +137,81 @@ writes artifacts under `artifacts/benchmark/robosuite-panda-lift/`. If Robosuite is not installed, the script exits with an explicit sidecar health failure and writes `robosuite_sidecar.log` with the import error. + +## LIBERO-PRO registered-task runtime demo + +LIBERO-PRO support follows the same runtime sidecar boundary as Robosuite, but the +sidecar must run in an environment that can import the LIBERO-PRO stack and has +prepared registered-suite assets. The DimOS process still does not import +LIBERO-PRO, Robosuite, or Torch; it starts `dimos_libero_pro_sidecar.server` in a +subprocess, resolves the described Panda motor surface, drives +`ControlCoordinator` through the SHM motor bridge, fetches `.npy` camera payloads, +and records sidecar-owned score output. + +Prepared assets are validated by default and are never downloaded or rearranged by +sidecar startup or `/health`. If asset preparation is desired, use the explicit +bootstrap path instead of relying on implicit startup mutation: + +```bash +uv run --with huggingface-hub python scripts/benchmarks/demo_libero_pro_runtime.py --prepare-assets +``` + +For already-prepared assets, run with a LIBERO-PRO-capable sidecar Python. If +that environment is separate from the main DimOS environment, point the demo at +it with `DIMOS_LIBERO_PRO_SIDECAR_PYTHON`: + +```bash +LIBERO_CONFIG_PATH=/path/to/libero-config \ +PYTHONPATH=/path/to/LIBERO-PRO \ +DIMOS_LIBERO_PRO_SIDECAR_PYTHON=/path/to/libero-env/bin/python \ +uv run python scripts/benchmarks/demo_libero_pro_runtime.py +``` + +To verify camera wiring live, run the LIBERO-PRO demo with the same DimOS Rerun +bridge path as the Robosuite demo: + +```bash +LIBERO_CONFIG_PATH=/path/to/libero-config \ +PYTHONPATH=/path/to/LIBERO-PRO \ +DIMOS_LIBERO_PRO_SIDECAR_PYTHON=/path/to/libero-env/bin/python \ +uv run python scripts/benchmarks/demo_libero_pro_runtime.py --rerun --camera-name agentview +``` + +For a live MuJoCo/Robosuite viewer window plus Rerun camera streaming, use +`--visual --rerun`: + +```bash +LIBERO_CONFIG_PATH=/path/to/libero-config \ +PYTHONPATH=/path/to/LIBERO-PRO \ +DIMOS_LIBERO_PRO_SIDECAR_PYTHON=/path/to/libero-env/bin/python \ +uv run python scripts/benchmarks/demo_libero_pro_runtime.py --visual --rerun --camera-name agentview +``` + +`--visual` defaults to a longer run and larger joint target amplitude than the +smoke config so the Panda arm motion is visible. The sidecar defaults +`MUJOCO_GL=glfw` for visual mode; override it in the environment if your display +stack needs a different MuJoCo backend. + +`--rerun` starts a private Rerun bridge and LCM bus, publishes fetched sidecar +camera payloads as DimOS `Image` / `CameraInfo` streams, and writes +`rerun_summary.json` plus raw/display JPEG samples under the demo artifact +directory. `--rerun-grpc-port`, `--rerun-lcm-port`, `--rerun-max-hz`, and +`--camera-jpeg-dump-every` mirror the Robosuite demo flags. Rerun is the +headless-friendly verification path when an interactive viewer cannot be opened. + +`LIBERO_CONFIG_PATH` must contain a `config.yaml` whose `bddl_files` and +`init_states` entries point at the prepared LIBERO-PRO assets. The extra +`PYTHONPATH` is only needed when running against a source checkout whose package +is not installed into the sidecar environment. + +The default config is +`dimos/benchmark/runtime/configs/libero_pro_goal_task0.json`. It declares backend +`libero-pro` with common runtime fields plus backend-specific options for the +registered suite, task index, init-state index, controller, camera names, horizon, +and BDDL/init-state roots. Missing BDDL or init-state assets should fail before +episode reset with a clear sidecar validation error. + +The scripted demo is a plumbing acceptance check, not an agent task-success +benchmark. It can pass when protocol stepping, motor command/state flow, camera +payload retrieval, score collection, artifact writing, and cleanup succeed even if +the scripted servo target does not solve the selected LIBERO-PRO task. diff --git a/openspec/changes/libero-pro-runtime-sidecar/.openspec.yaml b/openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/.openspec.yaml similarity index 100% rename from openspec/changes/libero-pro-runtime-sidecar/.openspec.yaml rename to openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/.openspec.yaml diff --git a/openspec/changes/libero-pro-runtime-sidecar/design.md b/openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/design.md similarity index 100% rename from openspec/changes/libero-pro-runtime-sidecar/design.md rename to openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/design.md diff --git a/openspec/changes/libero-pro-runtime-sidecar/proposal.md b/openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/proposal.md similarity index 100% rename from openspec/changes/libero-pro-runtime-sidecar/proposal.md rename to openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/proposal.md diff --git a/openspec/changes/libero-pro-runtime-sidecar/specs/benchmark-prelaunch-orchestration/spec.md b/openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/specs/benchmark-prelaunch-orchestration/spec.md similarity index 100% rename from openspec/changes/libero-pro-runtime-sidecar/specs/benchmark-prelaunch-orchestration/spec.md rename to openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/specs/benchmark-prelaunch-orchestration/spec.md diff --git a/openspec/changes/libero-pro-runtime-sidecar/specs/libero-pro-runtime-sidecar/spec.md b/openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/specs/libero-pro-runtime-sidecar/spec.md similarity index 100% rename from openspec/changes/libero-pro-runtime-sidecar/specs/libero-pro-runtime-sidecar/spec.md rename to openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/specs/libero-pro-runtime-sidecar/spec.md diff --git a/openspec/changes/libero-pro-runtime-sidecar/specs/scripted-runtime-demos/spec.md b/openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/specs/scripted-runtime-demos/spec.md similarity index 100% rename from openspec/changes/libero-pro-runtime-sidecar/specs/scripted-runtime-demos/spec.md rename to openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/specs/scripted-runtime-demos/spec.md diff --git a/openspec/changes/libero-pro-runtime-sidecar/tasks.md b/openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/tasks.md similarity index 56% rename from openspec/changes/libero-pro-runtime-sidecar/tasks.md rename to openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/tasks.md index f7ab281cca..0c6f3bdcea 100644 --- a/openspec/changes/libero-pro-runtime-sidecar/tasks.md +++ b/openspec/changes/archive/2026-06-27-libero-pro-runtime-sidecar/tasks.md @@ -1,53 +1,53 @@ ## 1. Runtime Config and Planning -- [ ] 1.1 Add `backend_options` support to `BenchmarkEpisodeConfig` without breaking existing fake and Robosuite configs. -- [ ] 1.2 Add typed LIBERO-PRO backend option validation for benchmark name, task order index, task index, init-state index, controller, cameras, horizon, and asset roots. -- [ ] 1.3 Update runtime plan resolution or demo-side validation so `libero-pro` plans fail before blueprint startup when backend, robot id, motor count, protocol version, or command modes mismatch sidecar metadata. -- [ ] 1.4 Add a LIBERO-PRO benchmark config fixture for one registered suite/task using common top-level fields plus `backend_options`. +- [x] 1.1 Add `backend_options` support to `BenchmarkEpisodeConfig` without breaking existing fake and Robosuite configs. +- [x] 1.2 Add typed LIBERO-PRO backend option validation for benchmark name, task order index, task index, init-state index, controller, cameras, horizon, and asset roots. +- [x] 1.3 Update runtime plan resolution or demo-side validation so `libero-pro` plans fail before blueprint startup when backend, robot id, motor count, protocol version, or command modes mismatch sidecar metadata. +- [x] 1.4 Add a LIBERO-PRO benchmark config fixture for one registered suite/task using common top-level fields plus `backend_options`. ## 2. LIBERO-PRO Sidecar Package -- [ ] 2.1 Create `packages/dimos-libero-pro-sidecar/` with package metadata, import-safe module structure, and console script entrypoints. -- [ ] 2.2 Implement lazy LIBERO-PRO imports so importing the sidecar package does not import `dimos`, `libero`, `robosuite`, or `torch`. -- [ ] 2.3 Implement sidecar configuration/state models for registered-suite task selection and runtime settings. -- [ ] 2.4 Implement `/health`, `/describe`, `/reset`, `/step`, `/score`, and `/payloads/{id}` endpoints using the existing runtime protocol models. -- [ ] 2.5 Keep the LIBERO-PRO HTTP server single-threaded for MuJoCo/Robosuite render-context safety. +- [x] 2.1 Create `packages/dimos-libero-pro-sidecar/` with package metadata, import-safe module structure, and console script entrypoints. +- [x] 2.2 Implement lazy LIBERO-PRO imports so importing the sidecar package does not import `dimos`, `libero`, `robosuite`, or `torch`. +- [x] 2.3 Implement sidecar configuration/state models for registered-suite task selection and runtime settings. +- [x] 2.4 Implement `/health`, `/describe`, `/reset`, `/step`, `/score`, and `/payloads/{id}` endpoints using the existing runtime protocol models. +- [x] 2.5 Keep the LIBERO-PRO HTTP server single-threaded for MuJoCo/Robosuite render-context safety. ## 3. LIBERO-PRO Task and Motor Runtime -- [ ] 3.1 Resolve registered LIBERO-PRO tasks via `get_benchmark(benchmark_name)(task_order_index)` and `benchmark.get_task(task_index)`. -- [ ] 3.2 Create and reset `OffScreenRenderEnv`, apply `set_init_state(init_states[init_state_index])`, and return initial protocol state/metadata. -- [ ] 3.3 Validate the selected controller/action space exposes Panda joint-position plus gripper with the expected motor order and action dimension. -- [ ] 3.4 Translate `MotorActionFrame` position commands into LIBERO-PRO environment actions and translate returned simulator state into `MotorStateFrame`. -- [ ] 3.5 Export configured camera observations as runtime observation frames with fetchable `.npy` payload references. -- [ ] 3.6 Normalize sidecar-owned score output with success, reward or score, steps, benchmark name, task name, language, and init-state index. +- [x] 3.1 Resolve registered LIBERO-PRO tasks via `get_benchmark(benchmark_name)(task_order_index)` and `benchmark.get_task(task_index)`. +- [x] 3.2 Create and reset `OffScreenRenderEnv`, apply `set_init_state(init_states[init_state_index])`, and return initial protocol state/metadata. +- [x] 3.3 Validate the selected controller/action space exposes Panda joint-position plus gripper with the expected motor order and action dimension. +- [x] 3.4 Translate `MotorActionFrame` position commands into LIBERO-PRO environment actions and translate returned simulator state into `MotorStateFrame`. +- [x] 3.5 Export configured camera observations as runtime observation frames with fetchable `.npy` payload references. +- [x] 3.6 Normalize sidecar-owned score output with success, reward or score, steps, benchmark name, task name, language, and init-state index. ## 4. Asset Bootstrap and Validation -- [ ] 4.1 Add explicit asset validation that detects missing BDDL and init-state assets for the selected registered suite/task before episode reset. -- [ ] 4.2 Add an optional asset bootstrap command or demo flag that uses Hugging Face APIs where enabled and stages supported assets into the expected LIBERO-PRO layout. -- [ ] 4.3 Ensure sidecar startup and `/health` validate assets without downloading or mutating local files unless explicit bootstrap was requested. -- [ ] 4.4 Document manual/prepared asset path usage and bootstrap behavior in the sidecar or runtime-sidecar docs. +- [x] 4.1 Add explicit asset validation that detects missing BDDL and init-state assets for the selected registered suite/task before episode reset. +- [x] 4.2 Add an optional asset bootstrap command or demo flag that uses Hugging Face APIs where enabled and stages supported assets into the expected LIBERO-PRO layout. +- [x] 4.3 Ensure sidecar startup and `/health` validate assets without downloading or mutating local files unless explicit bootstrap was requested. +- [x] 4.4 Document manual/prepared asset path usage and bootstrap behavior in the sidecar or runtime-sidecar docs. ## 5. Full-Control Demo -- [ ] 5.1 Add `scripts/benchmarks/demo_libero_pro_runtime.py` modeled on the Robosuite demo flow. -- [ ] 5.2 Launch the LIBERO-PRO sidecar subprocess, wait for health, fetch runtime description, and derive the resolved runtime plan. -- [ ] 5.3 Create the SHM motor owner and benchmark runtime hardware component for `HardwareComponent(adapter_type="benchmark_runtime")`. -- [ ] 5.4 Run a scripted `ControlCoordinator` servo loop that sends Panda motor position targets, posts `/step`, writes returned motor state to SHM, and records motor/protocol traces. -- [ ] 5.5 Fetch camera payloads and publish or record at least one camera observation through the runtime observation path. -- [ ] 5.6 Request `/score`, write score/artifact outputs, and guarantee sidecar, coordinator, and SHM cleanup on success and failure. +- [x] 5.1 Add `scripts/benchmarks/demo_libero_pro_runtime.py` modeled on the Robosuite demo flow. +- [x] 5.2 Launch the LIBERO-PRO sidecar subprocess, wait for health, fetch runtime description, and derive the resolved runtime plan. +- [x] 5.3 Create the SHM motor owner and benchmark runtime hardware component for `HardwareComponent(adapter_type="benchmark_runtime")`. +- [x] 5.4 Run a scripted `ControlCoordinator` servo loop that sends Panda motor position targets, posts `/step`, writes returned motor state to SHM, and records motor/protocol traces. +- [x] 5.5 Fetch camera payloads and publish or record at least one camera observation through the runtime observation path. +- [x] 5.6 Request `/score`, write score/artifact outputs, and guarantee sidecar, coordinator, and SHM cleanup on success and failure. ## 6. Always-On Tests -- [ ] 6.1 Add import-boundary tests ensuring the LIBERO-PRO sidecar package imports without loading `dimos`, `libero`, `robosuite`, or `torch`. -- [ ] 6.2 Add backend-options validation tests for valid registered-suite config and missing/invalid LIBERO-PRO option failures. -- [ ] 6.3 Add stubbed sidecar endpoint tests for `/describe`, `/reset`, `/step`, `/score`, and camera payload behavior without real LIBERO-PRO dependencies. -- [ ] 6.4 Add action-surface validation tests covering compatible Panda joint-position plus gripper metadata and incompatible OSC/action-dimension failures. -- [ ] 6.5 Add config/plan tests confirming existing fake and Robosuite configs still resolve after introducing `backend_options`. +- [x] 6.1 Add import-boundary tests ensuring the LIBERO-PRO sidecar package imports without loading `dimos`, `libero`, `robosuite`, or `torch`. +- [x] 6.2 Add backend-options validation tests for valid registered-suite config and missing/invalid LIBERO-PRO option failures. +- [x] 6.3 Add stubbed sidecar endpoint tests for `/describe`, `/reset`, `/step`, `/score`, and camera payload behavior without real LIBERO-PRO dependencies. +- [x] 6.4 Add action-surface validation tests covering compatible Panda joint-position plus gripper metadata and incompatible OSC/action-dimension failures. +- [x] 6.5 Add config/plan tests confirming existing fake and Robosuite configs still resolve after introducing `backend_options`. ## 7. Optional Real Integration -- [ ] 7.1 Add optional/manual test marker coverage for launching a real LIBERO-PRO sidecar with prepared assets. -- [ ] 7.2 Verify the full ControlCoordinator + SHM demo path against one registered LIBERO-PRO suite/task in a prepared environment. -- [ ] 7.3 Record manual verification instructions, required environment assumptions, asset preparation steps, and expected artifacts. +- [x] 7.1 Add optional/manual test marker coverage for launching a real LIBERO-PRO sidecar with prepared assets. +- [x] 7.2 Verify the full ControlCoordinator + SHM demo path against one registered LIBERO-PRO suite/task in a prepared environment. +- [x] 7.3 Record manual verification instructions, required environment assumptions, asset preparation steps, and expected artifacts. diff --git a/openspec/specs/benchmark-prelaunch-orchestration/spec.md b/openspec/specs/benchmark-prelaunch-orchestration/spec.md index 0843fcb513..f708d02e8e 100644 --- a/openspec/specs/benchmark-prelaunch-orchestration/spec.md +++ b/openspec/specs/benchmark-prelaunch-orchestration/spec.md @@ -5,12 +5,27 @@ Define how benchmark intent is resolved into concrete DimOS runtime launch mater ## Requirements ### Requirement: Benchmark episode config -The system SHALL support a benchmark episode config that declares benchmark intent, including backend selection, task identity, robot profile, control constraints, observation needs, evaluator expectations, and artifact destination before a DimOS blueprint is launched. +The system SHALL support a benchmark episode config that declares benchmark intent, including backend selection, task identity, robot profile, control constraints, observation needs, evaluator expectations, artifact destination, and backend-specific options before a DimOS blueprint is launched. #### Scenario: Robosuite task is declared as benchmark intent - **WHEN** an episode config names backend `robosuite`, env name `Lift`, robot `Panda`, controller profile, control frequency, horizon, and seed - **THEN** the config is treated as portable benchmark intent rather than as a precomputed DimOS hardware component +#### Scenario: LIBERO-PRO task is declared with backend options +- **WHEN** an episode config names backend `libero-pro` with common runtime fields and backend options for benchmark name, task order index, task index, init-state index, controller, cameras, horizon, and asset paths +- **THEN** the config is treated as portable benchmark intent without adding LIBERO-PRO-specific fields to the common top-level config surface + +### Requirement: Backend-specific option validation +The system SHALL validate backend-specific episode options before deriving a resolved runtime plan, using typed validation for LIBERO-PRO options without requiring shared runtime protocol models to expose LIBERO-PRO task types. + +#### Scenario: Valid LIBERO-PRO options are accepted +- **WHEN** a `libero-pro` episode config provides required registered-suite task selection and runtime options +- **THEN** validation produces typed backend options that can be passed to sidecar launch, reset, and demo orchestration + +#### Scenario: Missing LIBERO-PRO options fail before launch +- **WHEN** a `libero-pro` episode config omits required suite, task, init-state, or asset validation options +- **THEN** prelaunch fails before starting the DimOS blueprint and reports the invalid backend option + ### Requirement: Runtime prelaunch orchestration The system SHALL provide a prelaunch orchestrator that starts the simulator sidecar first, obtains live sidecar metadata, derives concrete DimOS launch material, and then launches the DimOS blueprint. @@ -33,6 +48,17 @@ The system SHALL derive a resolved runtime plan from live sidecar metadata and t - **WHEN** the episode config requests a robot profile that is incompatible with the sidecar-described motor surface - **THEN** prelaunch fails before starting the DimOS blueprint and records the mismatch +### Requirement: LIBERO-PRO resolved runtime plan validation +The system SHALL derive LIBERO-PRO resolved runtime plans from live sidecar metadata and SHALL reject mismatches between episode config and the sidecar-described motor surface, backend, protocol version, or robot identity before starting the DimOS blueprint. + +#### Scenario: LIBERO-PRO hardware component is derived from sidecar metadata +- **WHEN** the LIBERO-PRO sidecar reports an ordered Panda whole-body motor surface compatible with the requested robot id and degree of freedom count +- **THEN** the resolved runtime plan includes a matching benchmark runtime hardware component for the ControlCoordinator-facing adapter + +#### Scenario: LIBERO-PRO motor surface mismatch fails +- **WHEN** the LIBERO-PRO sidecar reports a backend, robot id, motor count, or supported command mode that is incompatible with the episode config +- **THEN** prelaunch fails before starting the DimOS blueprint and records the mismatch + ### Requirement: Runner owns both runtime lifetimes The benchmark runner SHALL remain the parent owner of both the simulator sidecar process/environment and the DimOS blueprint process/environment for the duration of a demo or benchmark episode. diff --git a/openspec/specs/libero-pro-runtime-sidecar/spec.md b/openspec/specs/libero-pro-runtime-sidecar/spec.md new file mode 100644 index 0000000000..02dd195b92 --- /dev/null +++ b/openspec/specs/libero-pro-runtime-sidecar/spec.md @@ -0,0 +1,86 @@ +## Purpose + +Define the LIBERO-PRO runtime sidecar package, registered-suite task selection, asset preparation boundary, motor-control contract, observation export, scoring, and verification split for LIBERO-PRO benchmark demos. + +## Requirements + +### Requirement: LIBERO-PRO sidecar package +The system SHALL provide a first-class LIBERO-PRO runtime sidecar package in the monorepo that depends on the runtime protocol package and isolates LIBERO-PRO-specific dependencies from the main DimOS package. + +#### Scenario: LIBERO-PRO sidecar imports without DimOS +- **WHEN** a developer imports the LIBERO-PRO sidecar package module in a normal DimOS development environment without LIBERO-PRO installed +- **THEN** the import succeeds without importing `dimos`, `libero`, `robosuite`, or `torch` + +#### Scenario: LIBERO-PRO sidecar installs in isolated environment +- **WHEN** a developer installs the LIBERO-PRO sidecar package in a LIBERO-PRO-compatible environment +- **THEN** the sidecar can start and import runtime protocol models without installing the main DimOS package + +### Requirement: Registered LIBERO-PRO task selection +The LIBERO-PRO sidecar SHALL support registered LIBERO-PRO benchmark suites in v1 using backend options for benchmark name, task order index, task index, init-state index, controller, cameras, horizon, and asset roots. + +#### Scenario: Registered task is described +- **WHEN** the sidecar is configured with a registered LIBERO-PRO benchmark name, task order index, task index, and init-state index +- **THEN** the runtime description includes task metadata such as benchmark name, task name, language, BDDL path, init-state index, controller, horizon, and camera configuration + +#### Scenario: Dynamic perturbation request is rejected +- **WHEN** v1 configuration requests dynamic perturbation generation instead of a registered prepared suite task +- **THEN** the sidecar rejects the setup with a clear error before starting an episode + +### Requirement: LIBERO-PRO asset validation and bootstrap +The system SHALL support explicit opt-in LIBERO-PRO runtime asset bootstrap while requiring sidecar startup and health checks to validate prepared assets without downloading or mutating local asset layout. + +#### Scenario: Prepared assets validate successfully +- **WHEN** required BDDL and init-state assets exist for the selected registered suite task +- **THEN** the sidecar health or setup validation reports the assets as usable without modifying them + +#### Scenario: Missing assets fail clearly +- **WHEN** required BDDL or init-state assets are missing for the selected registered suite task +- **THEN** the sidecar reports a clear validation failure that identifies the missing asset category before episode reset + +#### Scenario: Asset bootstrap is explicit +- **WHEN** a developer requests asset preparation through an explicit bootstrap command or demo flag +- **THEN** the system may retrieve and stage supported external assets and then validates the resulting layout before sidecar use + +### Requirement: LIBERO-PRO motor surface validation +The LIBERO-PRO sidecar SHALL expose the full-control v1 path only when the selected task and controller provide a Panda joint-position plus gripper whole-body motor surface compatible with DimOS motor action frames. + +#### Scenario: Compatible motor surface is described +- **WHEN** the selected LIBERO-PRO environment exposes the expected Panda joint-position plus gripper action surface +- **THEN** the runtime description reports a stable ordered motor surface with supported position command mode and the expected motor count + +#### Scenario: Incompatible controller fails fast +- **WHEN** the selected LIBERO-PRO environment exposes only OSC pose control or an action dimension that cannot be mapped to Panda joint-position plus gripper commands +- **THEN** the sidecar rejects the episode setup with a clear protocol error before accepting step requests + +### Requirement: LIBERO-PRO step ownership and observation export +The LIBERO-PRO sidecar SHALL own backend-native environment reset and step calls and SHALL translate runtime protocol action frames into LIBERO-PRO actions while exporting motor state, reward, done, success, and observation metadata. + +#### Scenario: LIBERO-PRO reset applies init state +- **WHEN** DimOS requests episode reset for a configured LIBERO-PRO registered task +- **THEN** the sidecar resets the environment, applies the selected init state, and returns initial motor state and task metadata + +#### Scenario: Motor step advances LIBERO-PRO +- **WHEN** DimOS sends a motor position action frame for the described Panda motor surface +- **THEN** the sidecar maps the action to the LIBERO-PRO environment step and returns motor state, reward, done, success if available, and observation frames + +#### Scenario: Camera payload can be fetched +- **WHEN** a LIBERO-PRO step response includes a camera observation with a payload reference +- **THEN** the DimOS runtime client can fetch the referenced `.npy` payload for stream publication and artifacts + +### Requirement: Sidecar-owned LIBERO-PRO score +The LIBERO-PRO sidecar SHALL provide normalized episode score output that includes backend-owned success extraction, reward or score, step count, and task metadata. + +#### Scenario: Score is collected after episode +- **WHEN** the LIBERO-PRO demo completes, times out, or reaches done +- **THEN** the runner can request score output from the sidecar and write success, reward or score, steps, benchmark name, task name, language, and init-state index with episode artifacts + +### Requirement: LIBERO-PRO verification split +The system SHALL verify LIBERO-PRO sidecar behavior with always-on contract tests that do not require real LIBERO-PRO dependencies or data, and SHALL keep real LIBERO-PRO execution behind optional/manual integration coverage. + +#### Scenario: Normal CI runs without LIBERO-PRO data +- **WHEN** normal test suites run in the main DimOS development environment +- **THEN** they verify import boundaries, backend option validation, stubbed sidecar endpoints, action-surface failures, and score shape without requiring LIBERO-PRO assets or dependencies + +#### Scenario: Manual integration exercises real LIBERO-PRO +- **WHEN** a developer runs the optional real LIBERO-PRO integration with prepared dependencies and assets +- **THEN** it launches the real sidecar, runs the full ControlCoordinator and SHM demo path for one registered task, fetches camera payloads, and writes score and artifacts diff --git a/openspec/specs/scripted-runtime-demos/spec.md b/openspec/specs/scripted-runtime-demos/spec.md index 48027b7bc3..bed923c691 100644 --- a/openspec/specs/scripted-runtime-demos/spec.md +++ b/openspec/specs/scripted-runtime-demos/spec.md @@ -38,6 +38,40 @@ The system SHALL include a script-based Robosuite Panda Lift plumbing demo that - **WHEN** a developer runs the Robosuite demo with Rerun stream visualization enabled repeatedly or alongside other DimOS publishers - **THEN** the demo uses isolated Rerun and local DimOS transport settings for its visualization path and applies a bounded Rerun memory limit so old recordings or unrelated camera topics do not mix with the demo stream +### Requirement: LIBERO-PRO full-control runtime demo +The system SHALL include a script-based LIBERO-PRO runtime demo that validates the real LIBERO-PRO sidecar, runtime description, registered task reset, local SHM motor bridge, ControlCoordinator integration, camera payload export, score collection, artifact output, and teardown. + +#### Scenario: LIBERO-PRO demo starts sidecar and DimOS control path +- **WHEN** a developer runs the LIBERO-PRO demo script with a compatible sidecar environment and prepared registered-suite assets +- **THEN** the script starts the sidecar, obtains runtime metadata, resolves the runtime plan, starts the DimOS control path, runs the configured tick loop, collects artifacts, and tears down both runtimes + +#### Scenario: LIBERO-PRO demo exercises motor command and state flow +- **WHEN** the LIBERO-PRO demo sends scripted Panda motor position targets through the ControlCoordinator-facing path +- **THEN** commands flow through the local SHM motor bridge to the runtime protocol client, and sidecar-returned motor states flow back into the DimOS side and are recorded in the motor trace + +#### Scenario: LIBERO-PRO camera payload is exported +- **WHEN** the LIBERO-PRO demo enables a configured camera observation stream +- **THEN** the demo fetches referenced `.npy` camera payloads and publishes or records at least one camera observation through the runtime observation path + +#### Scenario: LIBERO-PRO score is recorded +- **WHEN** the LIBERO-PRO demo completes, times out, or reaches done +- **THEN** the demo requests sidecar-owned score output and writes success, reward or score, step count, task metadata, protocol trace summary, motor trace, and logs to the artifact directory + +#### Scenario: LIBERO-PRO demo does not require agent task success +- **WHEN** the scripted LIBERO-PRO demo does not solve the selected task successfully +- **THEN** the demo can still pass if protocol, motor flow, observation flow, score collection, and teardown satisfy the demo acceptance checks + +### Requirement: LIBERO-PRO asset preparation remains explicit +The LIBERO-PRO scripted demo SHALL NOT download or mutate benchmark assets unless the developer passes an explicit asset preparation flag or runs an explicit preparation command. + +#### Scenario: Demo validates assets by default +- **WHEN** a developer runs the LIBERO-PRO demo without an asset preparation option +- **THEN** the demo validates prepared asset paths and fails clearly if required assets are missing + +#### Scenario: Demo prepares assets only when requested +- **WHEN** a developer runs the LIBERO-PRO demo with an explicit asset preparation option +- **THEN** the demo may run the runtime asset bootstrap before launching the sidecar and still validates the prepared layout before episode reset + ### Requirement: No agent success requirement The scripted demos SHALL verify runtime plumbing and MUST NOT require an LLM, MCP skill policy, or successful task completion by an agent. diff --git a/packages/dimos-libero-pro-sidecar/pyproject.toml b/packages/dimos-libero-pro-sidecar/pyproject.toml new file mode 100644 index 0000000000..1be8dc1b74 --- /dev/null +++ b/packages/dimos-libero-pro-sidecar/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "dimos-libero-pro-sidecar" +version = "0.1.0" +description = "LIBERO-PRO runtime sidecar for DimOS benchmark plumbing" +requires-python = ">=3.10" +dependencies = [ + "dimos-runtime-protocol", +] + +[project.optional-dependencies] +assets = ["huggingface-hub"] +libero = ["libero"] + +[project.scripts] +dimos-libero-pro-sidecar = "dimos_libero_pro_sidecar.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/dimos_libero_pro_sidecar"] diff --git a/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/__init__.py b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/__init__.py new file mode 100644 index 0000000000..82e869fa09 --- /dev/null +++ b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Import-safe LIBERO-PRO runtime sidecar package.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/assets.py b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/assets.py new file mode 100644 index 0000000000..2b8a490230 --- /dev/null +++ b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/assets.py @@ -0,0 +1,56 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Explicit LIBERO-PRO asset bootstrap command. + +This module intentionally does not run during sidecar import or health checks. +Call it directly when local benchmark assets should be downloaded/staged. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from dimos_libero_pro_sidecar.server import ( + LiberoProRuntimeConfig, + bootstrap_assets, + validate_assets, +) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + bootstrap = subparsers.add_parser("bootstrap", help="Download and validate LIBERO-PRO assets.") + bootstrap.add_argument("--benchmark-name", required=True) + bootstrap.add_argument("--bddl-root", type=Path, required=True) + bootstrap.add_argument("--init-states-root", type=Path, required=True) + bootstrap.add_argument("--task-index", type=int, default=0) + args = parser.parse_args() + + config = LiberoProRuntimeConfig( + host="127.0.0.1", + port=0, + benchmark_name=args.benchmark_name, + bddl_root=args.bddl_root, + init_states_root=args.init_states_root, + task_index=args.task_index, + ) + bootstrap_assets(config) + validate_assets(config) + + +if __name__ == "__main__": + main() diff --git a/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/server.py b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/server.py new file mode 100644 index 0000000000..55527cb931 --- /dev/null +++ b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/server.py @@ -0,0 +1,680 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Single-threaded HTTP sidecar for registered LIBERO-PRO tasks.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from dataclasses import dataclass +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, HTTPServer +from io import BytesIO +import json +from pathlib import Path +import time +from typing import ClassVar, Protocol, cast +from urllib.parse import unquote, urlparse + +from dimos_runtime_protocol import ( + CommandMode, + EpisodeResetRequest, + EpisodeResetResponse, + HealthResponse, + MotorDescription, + MotorStateFrame, + ObservationFrame, + ObservationKind, + ProtocolVersion, + RobotMotorSurface, + RuntimeDescription, + ScoreOutput, + StepRequest, + StepResponse, +) + + +def require_libero(*, visualize: bool = False) -> tuple[object, LiberoEnvFactory]: + """Import LIBERO-PRO dependencies only on the runtime path.""" + + try: + try: + from libero import benchmark as libero_benchmark + + if visualize: + from libero.envs.env_wrapper import ControlEnv + + env_cls = ControlEnv + else: + from libero.envs import OffScreenRenderEnv + + env_cls = OffScreenRenderEnv + except ImportError: + from libero.libero import benchmark as libero_benchmark + + if visualize: + from libero.libero.envs.env_wrapper import ControlEnv + + env_cls = ControlEnv + else: + from libero.libero.envs import OffScreenRenderEnv + + env_cls = OffScreenRenderEnv + except ImportError as exc: + raise RuntimeError( + "LIBERO-PRO dependencies are required for dimos-libero-pro-sidecar. " + "Install the sidecar in a LIBERO/Robosuite-compatible environment." + ) from exc + return libero_benchmark, cast("LiberoEnvFactory", env_cls) + + +@dataclass(frozen=True) +class LiberoProRuntimeConfig: + host: str + port: int + benchmark_name: str + bddl_root: Path + init_states_root: Path + robot_id: str = "panda" + task_order_index: int = 0 + task_index: int = 0 + init_state_index: int = 0 + controller: str = "JOINT_POSITION" + camera_names: tuple[str, ...] = ("agentview",) + control_freq: int = 20 + horizon: int = 1000 + seed: int | None = None + allow_asset_bootstrap: bool = False + visualize: bool = False + + +class LiberoEnv(Protocol): + action_spec: tuple[Sequence[float], Sequence[float]] + + def reset(self) -> dict[str, object]: ... + + def set_init_state(self, state: object) -> dict[str, object]: ... + + def step( + self, action: Sequence[float] + ) -> tuple[dict[str, object], float, bool, dict[str, object]]: ... + + +class LiberoEnvFactory(Protocol): + def __call__( + self, + *, + bddl_file_name: str, + robots: list[str], + use_camera_obs: bool, + has_renderer: bool, + has_offscreen_renderer: bool, + camera_heights: int, + camera_widths: int, + camera_names: list[str], + controller: str, + control_freq: int, + horizon: int, + render_camera: str | None = None, + ) -> LiberoEnv: ... + + +class LiberoBackend(Protocol): + action_low: Sequence[float] + action_high: Sequence[float] + task_name: str + language: str + + def reset(self, init_state_index: int) -> dict[str, object]: ... + + def step( + self, action: Sequence[float] + ) -> tuple[dict[str, object], float, bool, dict[str, object]]: ... + + +class RealLiberoBackend: + def __init__(self, config: LiberoProRuntimeConfig) -> None: + libero_benchmark, env_cls = require_libero(visualize=config.visualize) + benchmark_factory = libero_benchmark.get_benchmark(config.benchmark_name) + benchmark = benchmark_factory(config.task_order_index) + task = benchmark.get_task(config.task_index) + self.task_name = str(getattr(task, "name", f"task-{config.task_index}")) + self.language = str(getattr(task, "language", getattr(task, "problem_folder", ""))) + bddl_file = _task_bddl_file(config, benchmark, task) + init_states = _load_init_states(config, benchmark, task) + self._init_states = init_states + self._env: LiberoEnv = env_cls( + bddl_file_name=str(bddl_file), + robots=["Panda"], + use_camera_obs=True, + has_renderer=config.visualize, + has_offscreen_renderer=True, + camera_heights=128, + camera_widths=128, + camera_names=list(config.camera_names), + controller=config.controller, + control_freq=config.control_freq, + horizon=config.horizon, + render_camera=config.camera_names[0] if config.camera_names else None, + ) + self.action_low, self.action_high = _action_bounds(self._env) + + def reset(self, init_state_index: int) -> dict[str, object]: + obs = self._env.reset() + set_init_state = self._env.set_init_state + obs = set_init_state(self._init_states[init_state_index]) + return cast("dict[str, object]", obs) + + def step( + self, action: Sequence[float] + ) -> tuple[dict[str, object], float, bool, dict[str, object]]: + obs, reward, done, info = self._env.step(action) + typed_info = cast("dict[str, object]", info) + if not any(key in typed_info for key in ("success", "is_success", "task_success")): + check_success = getattr(self._env, "check_success", None) + if callable(check_success): + typed_info["success"] = bool(check_success()) + return cast("dict[str, object]", obs), float(reward), bool(done), typed_info + + def render(self) -> None: + render = getattr(self._env, "render", None) + if callable(render): + render() + return + wrapped_env = getattr(self._env, "env", None) + wrapped_render = getattr(wrapped_env, "render", None) + if callable(wrapped_render): + wrapped_render() + + +class LiberoProRuntimeState: + """Owns one LIBERO-PRO backend and maps it to runtime protocol models.""" + + def __init__( + self, config: LiberoProRuntimeConfig, backend: LiberoBackend | None = None + ) -> None: + self.config = config + validate_assets(config) + self._backend = backend or RealLiberoBackend(config) + self._episode_id = "uninitialized" + self._sequence = 0 + self._last_obs: dict[str, object] = {} + self._last_reward = 0.0 + self._last_done = False + self._last_success: bool | None = None + self._payloads: dict[str, bytes] = {} + self._action_low = [float(v) for v in self._backend.action_low] + self._action_high = [float(v) for v in self._backend.action_high] + self.motor_names = [f"{config.robot_id}/joint{i + 1}" for i in range(7)] + [ + f"{config.robot_id}/gripper" + ] + self._validate_action_surface() + + def _validate_action_surface(self) -> None: + if self.config.controller not in {"JOINT_POSITION", "PANDA_JOINT_POSITION"}: + raise RuntimeError( + f"unsupported LIBERO-PRO controller profile: {self.config.controller}" + ) + if len(self._action_low) != len(self.motor_names) or len(self._action_high) != len( + self.motor_names + ): + raise RuntimeError( + "LIBERO-PRO profile expects Panda 7 joint-position actions plus gripper: " + f"action_dim={len(self._action_low)} motors={len(self.motor_names)}" + ) + + def describe(self) -> RuntimeDescription: + return RuntimeDescription( + runtime_id="libero-pro", + backend="libero-pro", + capabilities=["sync-http", "whole-body-motor-position", "libero-pro"], + robot_surfaces=[ + RobotMotorSurface( + robot_id=self.config.robot_id, + motors=[ + MotorDescription(name=n, index=i) for i, n in enumerate(self.motor_names) + ], + supported_command_modes=[CommandMode.POSITION], + ) + ], + control_step_hz=self.config.control_freq, + observation_streams=[*self.config.camera_names, "robot_state"], + metadata={ + "benchmark_name": self.config.benchmark_name, + "task_order_index": self.config.task_order_index, + "task_index": self.config.task_index, + "task_name": self._backend.task_name, + "language": self._backend.language, + "bddl_root": str(self.config.bddl_root), + "init_states_root": str(self.config.init_states_root), + "init_state_index": self.config.init_state_index, + "controller": self.config.controller, + "horizon": self.config.horizon, + "visualize": self.config.visualize, + "camera_names": list(self.config.camera_names), + "action_low": self._action_low, + "action_high": self._action_high, + }, + ) + + def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: + self._episode_id = request.episode_id + self._sequence = 0 + self._last_reward = 0.0 + self._last_done = False + self._last_success = None + self._last_obs = self._backend.reset(self.config.init_state_index) + observations = self._observations(0) + self._render_if_enabled() + return EpisodeResetResponse( + episode_id=request.episode_id, + runtime_description=self.describe(), + observations=observations, + ) + + def step(self, request: StepRequest) -> StepResponse: + if request.action.robot_id != self.config.robot_id: + raise ValueError(f"unexpected robot id {request.action.robot_id!r}") + if request.action.names != self.motor_names: + raise ValueError("action motor names do not match runtime surface") + if request.action.mode != CommandMode.POSITION: + raise ValueError(f"unsupported command mode {request.action.mode}") + if len(request.action.q) != len(self.motor_names): + raise ValueError( + f"expected {len(self.motor_names)} q targets, got {len(request.action.q)}" + ) + action = [ + min(max(float(v), low), high) + for v, low, high in zip( + request.action.q, self._action_low, self._action_high, strict=True + ) + ] + obs, reward, done, info = self._backend.step(action) + self._sequence += 1 + self._last_obs = obs + self._last_reward = reward + self._last_done = done + self._last_success = _success_from_info(info) + observations = self._observations(request.tick_id) + self._render_if_enabled() + return StepResponse( + episode_id=request.episode_id, + tick_id=request.tick_id, + motor_state=self._motor_state(), + observations=observations, + reward=reward, + done=done, + success=self._last_success, + info={"backend_sequence": self._sequence}, + ) + + def _render_if_enabled(self) -> None: + if not self.config.visualize: + return + render = getattr(self._backend, "render", None) + if callable(render): + render() + + def score(self) -> ScoreOutput: + success = bool(self._last_success) + return ScoreOutput( + episode_id=self._episode_id, + success=success, + score=1.0 if success else float(self._last_reward), + reason="LIBERO-PRO success" if success else "success not observed", + metrics={ + "reward": self._last_reward, + "done": self._last_done, + "steps": self._sequence, + "benchmark_name": self.config.benchmark_name, + "task_name": self._backend.task_name, + "language": self._backend.language, + "init_state_index": self.config.init_state_index, + }, + ) + + def payload_bytes(self, payload_id: str) -> bytes: + try: + return self._payloads[payload_id] + except KeyError as exc: + raise FileNotFoundError(payload_id) from exc + + def _motor_state(self) -> MotorStateFrame: + q = [ + *_float_list(self._last_obs.get("robot0_joint_pos", []))[:7], + _mean_or_zero(_float_list(self._last_obs.get("robot0_gripper_qpos", []))), + ] + dq = [ + *_float_list(self._last_obs.get("robot0_joint_vel", []))[:7], + _mean_or_zero(_float_list(self._last_obs.get("robot0_gripper_qvel", []))), + ] + q = (q + [0.0] * len(self.motor_names))[: len(self.motor_names)] + dq = (dq + [0.0] * len(self.motor_names))[: len(self.motor_names)] + return MotorStateFrame( + robot_id=self.config.robot_id, + names=self.motor_names, + q=q, + dq=dq, + tau=[0.0] * len(self.motor_names), + sequence=self._sequence, + timestamp_s=time.time(), + ) + + def _observations(self, tick_id: int) -> list[ObservationFrame]: + frames = [ + ObservationFrame( + stream="robot_state", + kind=ObservationKind.STATE, + inline_text=f"tick={tick_id} reward={self._last_reward}", + metadata={"sequence": self._sequence}, + ) + ] + for camera_name in self.config.camera_names: + image = self._last_obs.get(f"{camera_name}_image") + if image is None: + continue + payload_id, payload = self._store_payload(camera_name, tick_id, image) + frames.append( + ObservationFrame( + stream=camera_name, + kind=ObservationKind.IMAGE, + encoding="npy", + shape=_shape_list(image), + dtype=str(getattr(image, "dtype", "")), + data_ref=f"/payloads/{payload_id}", + metadata={ + "sequence": self._sequence, + "camera_name": camera_name, + "camera_source": "libero_pro_observation", + "image_convention": "opengl", + "fov_y_deg": 45.0, + "payload_bytes": len(payload), + }, + ) + ) + return frames + + def _store_payload(self, stream: str, tick_id: int, value: object) -> tuple[str, bytes]: + payload_id = f"{stream}-{tick_id:06d}-{self._sequence:06d}.npy" + payload = _npy_bytes(value) + self._payloads[payload_id] = payload + return payload_id, payload + + +class LiberoProRuntimeHandler(BaseHTTPRequestHandler): + state: ClassVar[LiberoProRuntimeState] + + def do_GET(self) -> None: + if self.path == "/health": + try: + validate_assets(self.state.config) + self._write_model( + HealthResponse(ok=True, runtime_id="libero-pro", protocol=ProtocolVersion()) + ) + except Exception as exc: + self._write_model( + HealthResponse(ok=False, runtime_id="libero-pro", detail=str(exc)), + status=HTTPStatus.SERVICE_UNAVAILABLE, + ) + elif self.path == "/describe": + self._write_model(self.state.describe()) + elif self.path == "/score": + self._write_model(self.state.score()) + elif self.path.startswith("/payloads/"): + payload_id = unquote(urlparse(self.path).path.removeprefix("/payloads/")) + try: + self._write_bytes( + self.state.payload_bytes(payload_id), content_type="application/x-npy" + ) + except FileNotFoundError: + self.send_error(HTTPStatus.NOT_FOUND) + else: + self.send_error(HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: + try: + body = self.rfile.read(int(self.headers.get("content-length", "0"))).decode("utf-8") + payload = json.loads(body) if body else {} + if self.path == "/reset": + self._write_model(self.state.reset(EpisodeResetRequest.model_validate(payload))) + elif self.path == "/step": + self._write_model(self.state.step(StepRequest.model_validate(payload))) + else: + self.send_error(HTTPStatus.NOT_FOUND) + except Exception as exc: + self._write_json(HTTPStatus.BAD_REQUEST, {"code": "bad_request", "message": str(exc)}) + + def log_message(self, format: str, *args: object) -> None: + return + + def _write_model(self, model: object, *, status: HTTPStatus = HTTPStatus.OK) -> None: + dump = getattr(model, "model_dump", None) + self._write_json(status, dump(mode="json") if callable(dump) else model) + + def _write_json(self, status: HTTPStatus, payload: object) -> None: + data = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _write_bytes(self, payload: bytes, *, content_type: str) -> None: + self.send_response(HTTPStatus.OK) + self.send_header("content-type", content_type) + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def make_server( + config: LiberoProRuntimeConfig, *, state: LiberoProRuntimeState | None = None +) -> HTTPServer: + LiberoProRuntimeHandler.state = state or LiberoProRuntimeState(config) + return HTTPServer((config.host, config.port), LiberoProRuntimeHandler) + + +def validate_assets(config: LiberoProRuntimeConfig) -> None: + if config.allow_asset_bootstrap: + bootstrap_assets(config) + if not config.bddl_root.exists(): + raise FileNotFoundError(f"missing LIBERO-PRO BDDL root: {config.bddl_root}") + if not config.init_states_root.exists(): + raise FileNotFoundError(f"missing LIBERO-PRO init-states root: {config.init_states_root}") + if not _has_file(config.bddl_root, "*.bddl"): + raise FileNotFoundError(f"no LIBERO-PRO BDDL files under {config.bddl_root}") + if not ( + _has_file(config.init_states_root, "*.pt") + or _has_file(config.init_states_root, "*.pth") + or _has_file(config.init_states_root, "*.pruned_init") + or _has_file(config.init_states_root, "*.init") + ): + raise FileNotFoundError(f"no LIBERO-PRO init-state tensors under {config.init_states_root}") + + +def bootstrap_assets(config: LiberoProRuntimeConfig) -> None: + import os + + repo_id = os.environ.get("LIBERO_PRO_HF_REPO_ID") + if not repo_id: + raise RuntimeError( + "LIBERO-PRO asset bootstrap requires LIBERO_PRO_HF_REPO_ID; " + "prepare assets manually or set the explicit Hugging Face repo id" + ) + try: + from huggingface_hub import snapshot_download + except ImportError as exc: + raise RuntimeError("install huggingface_hub to use LIBERO-PRO asset bootstrap") from exc + local_dir = config.bddl_root.parent + snapshot_download( + repo_id=repo_id, + repo_type="dataset", + local_dir=str(local_dir), + local_dir_use_symlinks=False, + allow_patterns=["bddl_files/**", "init_files/**"], + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8767) + parser.add_argument("--benchmark-name", required=True) + parser.add_argument("--bddl-root", type=Path, required=True) + parser.add_argument("--init-states-root", type=Path, required=True) + parser.add_argument("--robot-id", default="panda") + parser.add_argument("--task-order-index", type=int, default=0) + parser.add_argument("--task-index", type=int, default=0) + parser.add_argument("--init-state-index", type=int, default=0) + parser.add_argument("--controller", default="JOINT_POSITION") + parser.add_argument("--control-freq", type=int, default=20) + parser.add_argument("--horizon", type=int, default=1000) + parser.add_argument("--seed", type=int, default=None) + parser.add_argument("--camera-name", action="append", dest="camera_names") + parser.add_argument("--allow-asset-bootstrap", action="store_true") + parser.add_argument("--visualize", action="store_true") + args = parser.parse_args() + config = LiberoProRuntimeConfig( + host=args.host, + port=args.port, + benchmark_name=args.benchmark_name, + bddl_root=args.bddl_root, + init_states_root=args.init_states_root, + robot_id=args.robot_id, + task_order_index=args.task_order_index, + task_index=args.task_index, + init_state_index=args.init_state_index, + controller=args.controller, + camera_names=tuple(args.camera_names or ["agentview"]), + control_freq=args.control_freq, + horizon=args.horizon, + seed=args.seed, + allow_asset_bootstrap=args.allow_asset_bootstrap, + visualize=args.visualize, + ) + server = make_server(config) + try: + server.serve_forever() + finally: + server.server_close() + + +def _task_bddl_file(config: LiberoProRuntimeConfig, benchmark: object, task: object) -> Path: + get_path = getattr(benchmark, "get_task_bddl_file_path", None) + if callable(get_path): + path = Path(str(get_path(config.task_index))) + return path if path.is_absolute() else config.bddl_root / path + bddl_file = getattr(task, "bddl_file", None) or getattr(task, "bddl_file_name", None) + if bddl_file is not None: + path = Path(str(bddl_file)) + return path if path.is_absolute() else config.bddl_root / path + return config.bddl_root / f"{getattr(task, 'name', f'task_{config.task_index}')}.bddl" + + +def _load_init_states( + config: LiberoProRuntimeConfig, benchmark: object, task: object +) -> Sequence[object]: + task_init_states_file = getattr(task, "init_states_file", None) + problem_folder = getattr(task, "problem_folder", None) + if task_init_states_file is not None: + path = Path(str(task_init_states_file)) + if not path.is_absolute(): + path = config.init_states_root / str(problem_folder or "") / path + if path.exists(): + return _torch_load_init_states(path) + get_states = getattr(benchmark, "get_task_init_states", None) + if callable(get_states): + try: + return cast("Sequence[object]", get_states(config.task_index)) + except Exception: + if task_init_states_file is None: + raise + + files = ( + sorted(config.init_states_root.rglob("*.pt")) + + sorted(config.init_states_root.rglob("*.pth")) + + sorted(config.init_states_root.rglob("*.pruned_init")) + + sorted(config.init_states_root.rglob("*.init")) + ) + if not files: + raise FileNotFoundError(f"no LIBERO-PRO init-state tensors under {config.init_states_root}") + return _torch_load_init_states(files[0]) + + +def _torch_load_init_states(path: Path) -> Sequence[object]: + import torch + + try: + states = torch.load(path, map_location="cpu", weights_only=False) + except TypeError: + states = torch.load(path, map_location="cpu") + return cast("Sequence[object]", states) + + +def _has_file(root: Path, pattern: str) -> bool: + return any(root.glob(pattern)) or any(root.rglob(pattern)) + + +def _action_bounds(env: LiberoEnv) -> tuple[list[float], list[float]]: + action_spec = getattr(env, "action_spec", None) + if action_spec is None: + wrapped_env = getattr(env, "env", None) + action_spec = getattr(wrapped_env, "action_spec", None) + if action_spec is None: + raise AttributeError("LIBERO-PRO environment does not expose action_spec") + low, high = action_spec + return _float_list(low), _float_list(high) + + +def _float_list(value: object) -> list[float]: + tolist = getattr(value, "tolist", None) + if callable(tolist): + value = tolist() + if isinstance(value, Sequence) and not isinstance(value, str | bytes): + return [float(item) for item in value] + return [] + + +def _mean_or_zero(values: Sequence[float]) -> float: + return float(sum(values) / len(values)) if values else 0.0 + + +def _shape_list(value: object) -> list[int]: + shape = getattr(value, "shape", None) + if isinstance(shape, Sequence): + return [int(item) for item in shape] + return [] + + +def _npy_bytes(value: object) -> bytes: + import numpy as np + + buffer = BytesIO() + np.save(buffer, np.asarray(value), allow_pickle=False) + return buffer.getvalue() + + +def _success_from_info(info: dict[str, object]) -> bool | None: + for key in ("success", "is_success", "task_success"): + value = info.get(key) + if isinstance(value, bool): + return value + if isinstance(value, int | float): + return bool(value) + return None + + +if __name__ == "__main__": + main() diff --git a/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/models.py b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/models.py index fdd3bb911a..bf52643291 100644 --- a/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/models.py +++ b/packages/dimos-runtime-protocol/src/dimos_runtime_protocol/models.py @@ -16,7 +16,16 @@ from __future__ import annotations -from enum import StrEnum +from enum import Enum + + +class StrEnum(str, Enum): + """Python 3.10-compatible subset of enum.StrEnum.""" + + def __str__(self) -> str: + return self.value + + from typing import Literal from pydantic import BaseModel, ConfigDict, Field, NonNegativeFloat, NonNegativeInt, PositiveInt @@ -82,8 +91,12 @@ class RobotMotorSurface(StrictModel): robot_id: str surface_type: Literal["whole_body"] = "whole_body" motors: list[MotorDescription] - supported_command_modes: list[CommandMode] = Field(default_factory=lambda: [CommandMode.POSITION]) - state_fields: list[Literal["q", "dq", "tau"]] = Field(default_factory=lambda: ["q", "dq", "tau"]) + supported_command_modes: list[CommandMode] = Field( + default_factory=lambda: [CommandMode.POSITION] + ) + state_fields: list[Literal["q", "dq", "tau"]] = Field( + default_factory=lambda: ["q", "dq", "tau"] + ) class RuntimeDescription(StrictModel): @@ -113,7 +126,7 @@ class EpisodeResetResponse(StrictModel): episode_id: str runtime_description: RuntimeDescription - observations: list["ObservationFrame"] = Field(default_factory=list) + observations: list[ObservationFrame] = Field(default_factory=list) class MotorActionFrame(StrictModel): diff --git a/scripts/benchmarks/demo_libero_pro_runtime.py b/scripts/benchmarks/demo_libero_pro_runtime.py new file mode 100644 index 0000000000..e899594853 --- /dev/null +++ b/scripts/benchmarks/demo_libero_pro_runtime.py @@ -0,0 +1,787 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run a LIBERO-PRO registered-task demo through the DimOS runtime sidecar path. + +The DimOS process intentionally does not import LIBERO-PRO, Robosuite, or Torch. +It starts the LIBERO-PRO sidecar in a subprocess and communicates through the +shared runtime protocol plus the local SHM motor bridge. +""" + +from __future__ import annotations + +import argparse +from io import BytesIO +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import time + +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[2] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +LIBERO_PRO_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-libero-pro-sidecar" / "src" + +for package_src in (PROTOCOL_SRC, LIBERO_PRO_SIDECAR_SRC): + sys.path.insert(0, str(package_src)) + +from dimos_runtime_protocol import ( + EpisodeResetRequest, + MotorActionFrame, + ObservationKind, + StepRequest, +) + +from dimos.benchmark.runtime.artifacts import write_json +from dimos.benchmark.runtime.config import ( + BenchmarkEpisodeConfig, + LiberoProBackendOptions, + resolve_runtime_plan, + validate_libero_pro_backend_options, +) +from dimos.control.components import HardwareComponent, HardwareType +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.hardware.whole_body.spec import MotorState +from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient +from dimos.simulation.runtime_client.shm_motor import MotorShmOwner + + +def _load_config(path: Path) -> BenchmarkEpisodeConfig: + return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) + + +def _sidecar_env(*, visualize: bool = False) -> dict[str, str]: + env = dict(os.environ) + existing = env.get("PYTHONPATH", "") + paths = [str(PROTOCOL_SRC), str(LIBERO_PRO_SIDECAR_SRC)] + if existing: + paths.append(existing) + env["PYTHONPATH"] = os.pathsep.join(paths) + env.setdefault("MUJOCO_GL", "glfw" if visualize else "egl") + return env + + +def _sidecar_python() -> str: + return os.environ.get("DIMOS_LIBERO_PRO_SIDECAR_PYTHON", sys.executable) + + +def _repo_path(path: Path) -> Path: + return path if path.is_absolute() else REPO_ROOT / path + + +def _common_sidecar_args( + config: BenchmarkEpisodeConfig, + options: LiberoProBackendOptions, +) -> list[str]: + command = [ + "--host", + config.runtime_host, + "--port", + str(config.runtime_port), + "--robot-id", + config.robot_id, + "--control-freq", + str(config.control_step_hz), + "--benchmark-name", + options.benchmark_name, + "--task-order-index", + str(options.task_order_index), + "--task-index", + str(options.task_index), + "--init-state-index", + str(options.init_state_index), + "--controller", + options.controller, + "--horizon", + str(options.horizon), + "--bddl-root", + str(_repo_path(options.bddl_root)), + "--init-states-root", + str(_repo_path(options.init_states_root)), + "--seed", + str(config.seed) if config.seed is not None else "0", + ] + if config.visualize: + command.append("--visualize") + for camera_name in options.camera_names: + command.extend(["--camera-name", camera_name]) + return command + + +def _prepare_assets(config: BenchmarkEpisodeConfig, options: LiberoProBackendOptions) -> None: + subprocess.run( + [ + _sidecar_python(), + "-m", + "dimos_libero_pro_sidecar.assets", + "bootstrap", + "--benchmark-name", + options.benchmark_name, + "--bddl-root", + str(_repo_path(options.bddl_root)), + "--init-states-root", + str(_repo_path(options.init_states_root)), + "--task-index", + str(options.task_index), + ], + cwd=REPO_ROOT, + env=_sidecar_env(), + check=True, + ) + + +def _start_libero_pro_sidecar( + config: BenchmarkEpisodeConfig, + options: LiberoProBackendOptions, +) -> subprocess.Popen[str]: + command = [ + _sidecar_python(), + "-m", + "dimos_libero_pro_sidecar.server", + *_common_sidecar_args(config, options), + ] + return subprocess.Popen( + command, + cwd=Path("/tmp/opencode"), + env=_sidecar_env(visualize=config.visualize), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +def _wait_sidecar_healthy( + sidecar: subprocess.Popen[str], + client: RuntimeSidecarClient, + timeout_s: float, +) -> object: + deadline = time.monotonic() + timeout_s + last_error = "" + while time.monotonic() < deadline: + if sidecar.poll() is not None: + raise RuntimeError("LIBERO-PRO sidecar exited before becoming healthy") + try: + return client.health() + except Exception as exc: + last_error = str(exc) + time.sleep(0.1) + raise RuntimeError(f"LIBERO-PRO sidecar did not become healthy: {last_error}") + + +def _command_frame(owner: MotorShmOwner, robot_id: str) -> tuple[int, MotorActionFrame]: + sequence, commands = owner.read_commands() + return sequence, MotorActionFrame( + robot_id=robot_id, + names=owner.motor_names, + q=[command.q for command in commands], + dq=[command.dq for command in commands], + kp=[command.kp for command in commands], + kd=[command.kd for command in commands], + tau=[command.tau for command in commands], + sequence=sequence, + ) + + +def _free_tcp_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _lcm_url(port: int) -> str: + return f"udpm://239.255.76.67:{port}?ttl=0" + + +class RerunStreamPublisher: + """Publish LIBERO-PRO runtime camera observations through DimOS streams.""" + + def __init__( + self, + *, + grpc_port: int, + lcm_port: int, + memory_limit: str, + max_hz: float, + topic_prefix: str = "/libero_pro_runtime", + ) -> None: + from dimos.core.transport import LCMTransport + from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo + from dimos.msgs.sensor_msgs.Image import Image + from dimos.protocol.pubsub.impl.lcmpubsub import LCM + from dimos.visualization.rerun.bridge import RerunBridgeModule + + prefix = topic_prefix.rstrip("/") + self._image_topic = f"{prefix}/color_image" + self._camera_info_topic = f"{prefix}/camera_info" + self._image_entity = "world/libero_pro_runtime/color_image" + self._camera_info_entity = "world/libero_pro_runtime/camera_info" + self._camera_info_published = False + + def runtime_camera_blueprint() -> object: + import rerun.blueprint as rrb + + return rrb.Blueprint( + rrb.Vertical( + rrb.Spatial2DView(origin=self._image_entity, name="LIBERO-PRO camera"), + ) + ) + + def topic_to_entity(topic: object) -> str: + topic_name = getattr(topic, "topic", None) + if not isinstance(topic_name, str): + topic_name = getattr(topic, "name", None) + if not isinstance(topic_name, str): + topic_name = str(topic).split("#")[0] + if topic_name == self._image_topic: + return self._image_entity + if topic_name == self._camera_info_topic: + return self._camera_info_entity + return f"world{topic_name}" + + max_hz_by_entity = {self._image_entity: max_hz} if max_hz > 0.0 else {} + lcm_url = _lcm_url(lcm_port) + self._image_transport = LCMTransport(self._image_topic, Image, url=lcm_url) + self._camera_info_transport = LCMTransport(self._camera_info_topic, CameraInfo, url=lcm_url) + self._bridge = RerunBridgeModule( + pubsubs=[LCM(url=lcm_url)], + blueprint=runtime_camera_blueprint, + connect_url=f"rerun+http://127.0.0.1:{grpc_port}/proxy", + memory_limit=memory_limit, + max_hz=max_hz_by_entity, + topic_to_entity=topic_to_entity, + visual_override={ + self._camera_info_entity: lambda camera_info: camera_info.to_rerun( + image_topic=self._image_entity + ), + }, + ) + + def start(self) -> None: + self._bridge.start() + + def stop(self) -> None: + self._image_transport.stop() + self._camera_info_transport.stop() + self._bridge.stop() + + def publish_rgb(self, rgb: object, *, fov_y_deg: float, frame_id: str) -> None: + from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo + from dimos.msgs.sensor_msgs.Image import Image, ImageFormat + + array = np.asarray(rgb) + if array.ndim != 3 or array.shape[2] < 3: + raise ValueError(f"expected HxWx3 RGB image, got shape {array.shape}") + image = Image.from_numpy(array[:, :, :3], format=ImageFormat.RGB, frame_id="") + camera_info = CameraInfo.from_fov( + fov_y_deg, + width=image.width, + height=image.height, + axis="vertical", + frame_id=frame_id, + ).with_ts(image.ts) + self._image_transport.broadcast(None, image) + if not self._camera_info_published: + self._camera_info_transport.broadcast(None, camera_info) + self._camera_info_published = True + + +def _target_for_tick(plan_target: float, motor_count: int, tick: int) -> list[float]: + phase = 1.0 if (tick // 50) % 2 == 0 else -1.0 + arm_pattern = [1.0, -0.8, 0.6, -0.4, 0.3, -0.2, 0.1] + targets = [plan_target * phase * scale for scale in arm_pattern[:motor_count]] + if motor_count > 7: + targets.extend([plan_target * phase] * (motor_count - 7)) + return targets[:motor_count] + + +def _safe_payload_name(data_ref: str) -> str: + return data_ref.strip("/").replace("/", "_") or "payload" + + +def _write_rgb_jpeg(path: Path, rgb: object) -> None: + import cv2 + + array = np.asarray(rgb) + if array.ndim != 3 or array.shape[2] < 3: + raise ValueError(f"expected HxWx3 RGB image, got shape {array.shape}") + path.parent.mkdir(parents=True, exist_ok=True) + bgr = cv2.cvtColor(array[:, :, :3], cv2.COLOR_RGB2BGR) + if not cv2.imwrite(str(path), bgr): + raise RuntimeError(f"failed to write JPEG {path}") + + +def _publish_rerun_observations( + client: RuntimeSidecarClient, + response_observations: object, + publisher: RerunStreamPublisher | None, + *, + image_dump_dir: Path | None = None, + image_dump_label: str = "frame", +) -> int: + if publisher is None and image_dump_dir is None: + return 0 + if not isinstance(response_observations, list): + return 0 + published = 0 + for frame in response_observations: + if getattr(frame, "kind", None) != ObservationKind.IMAGE: + continue + data_ref = getattr(frame, "data_ref", None) + if not isinstance(data_ref, str): + continue + payload = client.payload(data_ref) + image = np.load(BytesIO(payload), allow_pickle=False) + metadata = getattr(frame, "metadata", {}) + fov_y_deg = 45.0 + display_image = image + if isinstance(metadata, dict): + maybe_fov = metadata.get("fov_y_deg") + if isinstance(maybe_fov, int | float): + fov_y_deg = float(maybe_fov) + if metadata.get("image_convention") == "opengl": + display_image = np.flipud(image) + stream = getattr(frame, "stream", "camera") + frame_id = stream if isinstance(stream, str) else "camera" + if image_dump_dir is not None: + _write_rgb_jpeg(image_dump_dir / f"{image_dump_label}_{frame_id}_raw.jpg", image) + _write_rgb_jpeg( + image_dump_dir / f"{image_dump_label}_{frame_id}_display.jpg", + display_image, + ) + if publisher is not None: + publisher.publish_rgb(display_image, fov_y_deg=fov_y_deg, frame_id=frame_id) + published += 1 + return published + + +def _fetch_camera_payloads( + client: RuntimeSidecarClient, + observations: object, + payload_dir: Path, + label: str, +) -> list[dict[str, object]]: + if not isinstance(observations, list): + return [] + records: list[dict[str, object]] = [] + for frame in observations: + if getattr(frame, "kind", None) != ObservationKind.IMAGE: + continue + data_ref = getattr(frame, "data_ref", None) + if not isinstance(data_ref, str): + continue + payload = client.payload(data_ref) + array = np.load(BytesIO(payload), allow_pickle=False) + payload_path = payload_dir / f"{label}_{_safe_payload_name(data_ref)}.npy" + payload_path.parent.mkdir(parents=True, exist_ok=True) + payload_path.write_bytes(payload) + records.append( + { + "stream": getattr(frame, "stream", "camera"), + "data_ref": data_ref, + "path": str(payload_path), + "shape": list(array.shape), + "dtype": str(array.dtype), + "min": float(array.min()) if array.size else 0.0, + "max": float(array.max()) if array.size else 0.0, + } + ) + return records + + +def _trace_summary(trace: list[dict[str, object]]) -> dict[str, object]: + final = trace[-1] if trace else {} + stream_set: set[str] = set() + payload_count = 0 + for entry in trace: + value = entry.get("observation_streams", []) + if isinstance(value, list): + stream_set.update(stream for stream in value if isinstance(stream, str)) + payloads = entry.get("camera_payloads", []) + if isinstance(payloads, list): + payload_count += len(payloads) + return { + "ticks": len(trace), + "first_command_sequence": trace[0].get("command_sequence") if trace else None, + "final_command_sequence": final.get("command_sequence"), + "final_state_sequence": final.get("state_sequence"), + "final_command_q": final.get("command_q"), + "final_state_q": final.get("state_q"), + "observation_streams": sorted(stream_set), + "camera_payload_count": payload_count, + "final_reward": final.get("reward"), + "final_done": final.get("done"), + "final_success": final.get("success"), + } + + +def run_demo_config( + config: BenchmarkEpisodeConfig, + *, + prepare_assets: bool = False, + rerun: bool = False, + rerun_memory_limit: str = "128MB", + rerun_grpc_port: int = 0, + rerun_lcm_port: int = 0, + rerun_max_hz: float = 10.0, + camera_jpeg_dump_every: int = 25, +) -> Path: + options = validate_libero_pro_backend_options(config) + if prepare_assets: + _prepare_assets(config, options) + artifact_dir = (REPO_ROOT / config.artifact_dir).resolve() + payload_dir = artifact_dir / "camera_payloads" + client_image_dump_dir = artifact_dir / "images" / "client" + sidecar = _start_libero_pro_sidecar(config, options) + client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") + owner: MotorShmOwner | None = None + coordinator: ControlCoordinator | None = None + rerun_publisher: RerunStreamPublisher | None = None + selected_rerun_grpc_port: int | None = None + selected_rerun_lcm_port: int | None = None + published_rerun_frames = 0 + sidecar_output = "" + cleanup_status: dict[str, object] = { + "coordinator_stopped": False, + "shm_unlinked": False, + "sidecar_stopped": False, + } + try: + try: + health = _wait_sidecar_healthy(sidecar, client, timeout_s=30.0) + except RuntimeError as exc: + raise RuntimeError( + "LIBERO-PRO sidecar did not become healthy. Run this demo from an " + "environment that can import LIBERO-PRO and has prepared BDDL/init assets." + ) from exc + description = client.describe() + plan = resolve_runtime_plan(config, description) + reset = client.reset( + EpisodeResetRequest( + episode_id=plan.episode_id, + task_id=plan.task_id, + seed=config.seed, + options=config.backend_options, + ) + ) + + owner = MotorShmOwner(plan.shm_key, plan.motor_names) + owner.write_state([MotorState(q=0.0) for _ in plan.motor_names], sequence=0) + + hardware = HardwareComponent( + hardware_id=plan.robot_id, + hardware_type=HardwareType.WHOLE_BODY, + joints=plan.motor_names, + adapter_type="benchmark_runtime", + address=plan.shm_key, + adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, + ) + task_name = f"servo_{plan.robot_id}" + coordinator = ControlCoordinator( + tick_rate=float(plan.control_step_hz), + publish_joint_state=False, + hardware=[hardware], + tasks=[ + TaskConfig( + name=task_name, + type="servo", + joint_names=plan.motor_names, + auto_start=True, + params={"timeout": 0.0, "default_positions": [0.0] * len(plan.motor_names)}, + ) + ], + ) + coordinator.start() + if rerun: + selected_rerun_grpc_port = rerun_grpc_port if rerun_grpc_port > 0 else _free_tcp_port() + selected_rerun_lcm_port = rerun_lcm_port if rerun_lcm_port > 0 else _free_tcp_port() + rerun_publisher = RerunStreamPublisher( + grpc_port=selected_rerun_grpc_port, + lcm_port=selected_rerun_lcm_port, + memory_limit=rerun_memory_limit, + max_hz=rerun_max_hz, + ) + rerun_publisher.start() + + trace: list[dict[str, object]] = [] + reset_payloads = _fetch_camera_payloads(client, reset.observations, payload_dir, "reset") + if rerun and camera_jpeg_dump_every > 0: + _publish_rerun_observations( + client, + reset.observations, + None, + image_dump_dir=client_image_dump_dir, + image_dump_label="reset", + ) + for tick in range(plan.ticks): + target = _target_for_tick(plan.target_position, len(plan.motor_names), tick) + accepted = coordinator.task_invoke( + task_name, "set_target", {"positions": target, "t_now": None} + ) + if accepted is not True: + raise RuntimeError(f"servo task rejected target at tick {tick}") + time.sleep(1.0 / plan.control_step_hz) + command_sequence, action = _command_frame(owner, plan.robot_id) + response = client.step( + StepRequest(episode_id=plan.episode_id, tick_id=tick, action=action) + ) + camera_payloads = _fetch_camera_payloads( + client, response.observations, payload_dir, f"tick_{tick:06d}" + ) + should_dump_jpeg = ( + rerun and camera_jpeg_dump_every > 0 and tick % camera_jpeg_dump_every == 0 + ) + published_rerun_frames += _publish_rerun_observations( + client, + response.observations, + rerun_publisher, + image_dump_dir=client_image_dump_dir if should_dump_jpeg else None, + image_dump_label=f"tick_{tick:06d}", + ) + owner.write_state( + [ + MotorState( + q=response.motor_state.q[i], + dq=response.motor_state.dq[i], + tau=response.motor_state.tau[i], + ) + for i in range(len(plan.motor_names)) + ], + sequence=response.motor_state.sequence, + ) + trace.append( + { + "tick": tick, + "command_sequence": command_sequence, + "state_sequence": response.motor_state.sequence, + "command_q": action.q, + "state_q": response.motor_state.q, + "observation_streams": [frame.stream for frame in response.observations], + "camera_payloads": camera_payloads, + "rerun_frames_published": published_rerun_frames, + "reward": response.reward, + "done": response.done, + "success": response.success, + } + ) + if response.done: + break + + if config.visualize: + print("visual demo complete; keeping LIBERO-PRO viewer open for 5 seconds") + time.sleep(5.0) + + score = client.score() + write_json(artifact_dir / "episode_config.json", config) + write_json(artifact_dir / "runtime_description.json", description) + write_json(artifact_dir / "resolved_runtime_plan.json", plan) + write_json(artifact_dir / "reset_response.json", reset) + write_json(artifact_dir / "reset_camera_payloads.json", reset_payloads) + write_json(artifact_dir / "motor_trace.json", trace) + write_json(artifact_dir / "protocol_trace_summary.json", _trace_summary(trace)) + write_json( + artifact_dir / "rerun_summary.json", + { + "enabled": rerun, + "frames_published": published_rerun_frames, + "memory_limit": rerun_memory_limit, + "max_hz": rerun_max_hz, + "grpc_port": selected_rerun_grpc_port, + "lcm_port": selected_rerun_lcm_port, + "client_jpeg_dump_dir": str(client_image_dump_dir) + if rerun and camera_jpeg_dump_every > 0 + else None, + "jpeg_dump_every": camera_jpeg_dump_every, + }, + ) + write_json(artifact_dir / "score.json", score) + write_json(artifact_dir / "health.json", health) + return artifact_dir + finally: + if rerun_publisher is not None: + try: + rerun_publisher.stop() + cleanup_status["rerun_stopped"] = True + except Exception as exc: + cleanup_status["rerun_error"] = str(exc) + if coordinator is not None: + try: + coordinator.stop() + cleanup_status["coordinator_stopped"] = True + except Exception as exc: + cleanup_status["coordinator_error"] = str(exc) + if owner is not None: + try: + owner.close() + owner.unlink() + cleanup_status["shm_unlinked"] = True + except Exception as exc: + cleanup_status["shm_error"] = str(exc) + sidecar.terminate() + try: + sidecar_output, _ = sidecar.communicate(timeout=2.0) + except subprocess.TimeoutExpired: + sidecar.kill() + sidecar_output, _ = sidecar.communicate(timeout=2.0) + cleanup_status["sidecar_returncode"] = sidecar.returncode + cleanup_status["sidecar_stopped"] = sidecar.returncode is not None + sidecar_log = artifact_dir / "libero_pro_sidecar.log" + sidecar_log.parent.mkdir(parents=True, exist_ok=True) + sidecar_log.write_text(sidecar_output) + write_json(sidecar_log.parent / "cleanup_status.json", cleanup_status) + + +def run_demo(config_path: Path) -> Path: + return run_demo_config(_load_config(config_path), prepare_assets=False) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", + type=Path, + default=REPO_ROOT + / "dimos" + / "benchmark" + / "runtime" + / "configs" + / "libero_pro_goal_task0.json", + ) + parser.add_argument( + "--prepare-assets", + action="store_true", + help="Run the explicit LIBERO-PRO asset bootstrap before sidecar startup.", + ) + parser.add_argument( + "--visual", + action="store_true", + help="Open the LIBERO/Robosuite viewer and run a longer moving command sequence.", + ) + parser.add_argument("--ticks", type=int, default=None, help="Override configured tick count.") + parser.add_argument( + "--horizon", + type=int, + default=None, + help="Override LIBERO-PRO episode horizon.", + ) + parser.add_argument( + "--target-position", + type=float, + default=None, + help="Override configured joint-position target amplitude.", + ) + parser.add_argument( + "--camera-name", + action="append", + dest="camera_names", + help="Override LIBERO-PRO camera name. Repeat for multiple cameras.", + ) + parser.add_argument( + "--rerun", + action="store_true", + help="Publish LIBERO-PRO camera payloads to Rerun through DimOS streams.", + ) + parser.add_argument( + "--rerun-memory-limit", + default="128MB", + help="Memory cap for the Rerun server/viewer used by --rerun.", + ) + parser.add_argument( + "--rerun-grpc-port", + type=int, + default=0, + help="Rerun gRPC port for --rerun. 0 selects a free port.", + ) + parser.add_argument( + "--rerun-lcm-port", + type=int, + default=0, + help="Private LCM multicast port for --rerun streams. 0 selects a free port.", + ) + parser.add_argument( + "--rerun-max-hz", + type=float, + default=10.0, + help="Maximum image publish rate to Rerun. Use <=0 for every simulator tick.", + ) + parser.add_argument( + "--camera-jpeg-dump-every", + type=int, + default=25, + help="When --rerun is enabled, dump every Nth camera payload as JPEGs. Use <=0 to disable.", + ) + args = parser.parse_args() + try: + config = _load_config(args.config) + updates: dict[str, object] = {} + if args.visual: + updates["visualize"] = True + ticks = args.ticks if args.ticks is not None else max(config.ticks, 600) + updates["ticks"] = ticks + updates["target_position"] = ( + args.target_position + if args.target_position is not None + else max(abs(config.target_position), 0.9) + ) + backend_options = dict(config.backend_options) + backend_options["horizon"] = ( + args.horizon + if args.horizon is not None + else max(int(backend_options.get("horizon", 1000)), ticks + 1) + ) + updates["backend_options"] = backend_options + elif args.rerun and args.target_position is None: + # The default config target is intentionally tiny for smoke tests. + # Live verification should visibly move the arm, not only the gripper. + updates["target_position"] = max(abs(config.target_position), 0.9) + if args.ticks is not None: + updates["ticks"] = args.ticks + if args.target_position is not None: + updates["target_position"] = args.target_position + if args.horizon is not None and not args.visual: + backend_options = dict(config.backend_options) + backend_options["horizon"] = args.horizon + updates["backend_options"] = backend_options + if args.camera_names: + existing_backend_options = updates.get("backend_options") + if isinstance(existing_backend_options, dict): + backend_options = dict(existing_backend_options) + else: + backend_options = dict(config.backend_options) + backend_options["camera_names"] = args.camera_names + updates["backend_options"] = backend_options + if updates: + config = BenchmarkEpisodeConfig.model_validate({**config.model_dump(), **updates}) + artifact_dir = run_demo_config( + config, + prepare_assets=args.prepare_assets, + rerun=args.rerun, + rerun_memory_limit=args.rerun_memory_limit, + rerun_grpc_port=args.rerun_grpc_port, + rerun_lcm_port=args.rerun_lcm_port, + rerun_max_hz=args.rerun_max_hz, + camera_jpeg_dump_every=args.camera_jpeg_dump_every, + ) + except Exception as exc: + print(json.dumps({"ok": False, "reason": str(exc)}, indent=2)) + sys.exit(2) + print(json.dumps({"ok": True, "artifact_dir": str(artifact_dir)}, indent=2)) + + +if __name__ == "__main__": + main() From f63ddc39a0653a7cc41ba7f1ea8885160feaf8ed Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 27 Jun 2026 12:09:55 -0700 Subject: [PATCH 094/110] feat: add manipulation trajectory parametrization --- .../agentic_manipulation_module.py | 25 ++ .../manipulation/agentic_manipulation_spec.py | 2 + dimos/manipulation/manipulation_module.py | 380 ++++++++++++++---- dimos/manipulation/planning/spec/config.py | 27 ++ dimos/manipulation/planning/spec/enums.py | 20 + dimos/manipulation/planning/spec/models.py | 47 +++ dimos/manipulation/planning/spec/protocols.py | 27 ++ .../joint_trajectory_generator.py | 4 +- .../trajectory_generator/parametrizers.py | 190 +++++++++ .../test_parametrizers.py | 149 +++++++ .../planning/world/roboplan_world.py | 231 ++++++++++- .../test_agentic_manipulation_module.py | 61 ++- dimos/manipulation/test_manipulation_unit.py | 356 ++++++++++++++-- dimos/manipulation/test_roboplan_world.py | 118 +++++- dimos/manipulation/visualization/viser/gui.py | 41 ++ .../viser/test_viser_visualization.py | 60 ++- .../design.md | 125 ------ .../proposal.md | 35 -- .../tasks.md | 40 -- .../spec.md | 61 +++ .../spec.md | 65 ++- pyproject.toml | 13 +- uv.lock | 18 +- 23 files changed, 1745 insertions(+), 350 deletions(-) create mode 100644 dimos/manipulation/planning/trajectory_generator/parametrizers.py create mode 100644 dimos/manipulation/planning/trajectory_generator/test_parametrizers.py delete mode 100644 openspec/changes/add-manipulation-trajectory-parametrization-spec/design.md delete mode 100644 openspec/changes/add-manipulation-trajectory-parametrization-spec/proposal.md delete mode 100644 openspec/changes/add-manipulation-trajectory-parametrization-spec/tasks.md create mode 100644 openspec/specs/manipulation-next-plan-speed-control/spec.md rename openspec/{changes/add-manipulation-trajectory-parametrization-spec => }/specs/manipulation-trajectory-parametrization/spec.md (57%) diff --git a/dimos/manipulation/agentic_manipulation_module.py b/dimos/manipulation/agentic_manipulation_module.py index 77f565fd22..d466369b08 100644 --- a/dimos/manipulation/agentic_manipulation_module.py +++ b/dimos/manipulation/agentic_manipulation_module.py @@ -49,6 +49,31 @@ def move_to_joints( """ return self._manipulation.move_to_joints(joints, robot_name) + @skill + def set_motion_speed(self, speed_scale: float) -> SkillResult[ManipulationSkillError]: + """Set runtime manipulation motion speed for future motions. + + Args: + speed_scale: Speed multiplier in the range `(0, 1]`. Use values below + 1.0 for slower, gentler motion. + """ + if not self._manipulation.set_motion_speed(speed_scale): + return SkillResult[ManipulationSkillError].fail( + "INVALID_INPUT", + "Motion speed scale must be greater than 0 and less than or equal to 1.", + ) + return SkillResult[ManipulationSkillError].ok( + f"Motion speed scale set to {speed_scale:.2f}x. Re-plan to apply it." + ) + + @skill + def get_motion_speed(self) -> SkillResult[ManipulationSkillError]: + """Get the current runtime manipulation motion speed scale.""" + speed_scale = self._manipulation.get_motion_speed() + return SkillResult[ManipulationSkillError].ok( + f"Current motion speed scale is {speed_scale:.2f}x.", speed_scale=speed_scale + ) + @skill def open_gripper(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: """Open the robot gripper fully. diff --git a/dimos/manipulation/agentic_manipulation_spec.py b/dimos/manipulation/agentic_manipulation_spec.py index d904fab37e..2b4c929869 100644 --- a/dimos/manipulation/agentic_manipulation_spec.py +++ b/dimos/manipulation/agentic_manipulation_spec.py @@ -30,6 +30,8 @@ def get_robot_state( def move_to_joints( self, joints: str, robot_name: str | None = None ) -> SkillResult[ManipulationSkillError]: ... + def set_motion_speed(self, speed_scale: float) -> bool: ... + def get_motion_speed(self) -> float: ... def open_gripper( self, robot_name: str | None = None ) -> SkillResult[ManipulationSkillError]: ... diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 1be640c98f..2ea3176591 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -24,12 +24,12 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from enum import Enum import threading import time import traceback -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import TYPE_CHECKING, Any, TypeAlias, cast from pydantic import Field @@ -43,6 +43,7 @@ from dimos.manipulation.planning.groups.identifiers import ( assert_global_joint_names, assert_local_joint_names, + local_joint_name_from_global, make_global_joint_names, ) from dimos.manipulation.planning.groups.models import PlanningGroup @@ -56,23 +57,40 @@ PinkKinematicsConfig, ) from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor -from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType +from dimos.manipulation.planning.spec.config import ( + RobotModelConfig, + TrajectoryParametrizationConfig, +) +from dimos.manipulation.planning.spec.enums import ( + IKStatus, + ObstacleType, + ParametrizationStatus, + TrajectoryDispatchStatus, +) from dimos.manipulation.planning.spec.models import ( CollisionCheckResult, ForwardKinematicsResult, GeneratedPlan, + GeneratedTrajectory, IKResult, Obstacle, PlanningGroupID, PlanningResult, RobotName, + TrajectoryDispatch, WorldRobotID, ) -from dimos.manipulation.planning.spec.protocols import KinematicsSpec, PlannerSpec +from dimos.manipulation.planning.spec.protocols import ( + KinematicsSpec, + PlannerSpec, + TrajectoryParametrizerSpec, +) from dimos.manipulation.planning.trajectory_generator.joint_trajectory_generator import ( JointTrajectoryGenerator, ) +from dimos.manipulation.planning.trajectory_generator.parametrizers import ( + create_trajectory_parametrizer, +) from dimos.manipulation.skill_errors import ManipulationSkillError from dimos.manipulation.visualization.config import ( ManipulationVisualizationConfig, @@ -86,6 +104,7 @@ from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.sensor_msgs.JointState import JointState from dimos.msgs.trajectory_msgs.JointTrajectory import JointTrajectory +from dimos.msgs.trajectory_msgs.TrajectoryPoint import TrajectoryPoint from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: @@ -135,6 +154,9 @@ class ManipulationModuleConfig(ModuleConfig): # Set to None to disable. floor_z: float | None = None coordinator_rpc_timeout: float = 3.0 + trajectory_parametrization: TrajectoryParametrizationConfig = Field( + default_factory=TrajectoryParametrizationConfig + ) class ManipulationModule(Module): @@ -164,12 +186,15 @@ def __init__(self, **kwargs: Any) -> None: self._world_monitor: WorldMonitor | None = None self._planner: PlannerSpec | None = None self._kinematics: KinematicsSpec | None = None + self._trajectory_parametrizer: TrajectoryParametrizerSpec | None = None # Robot registry: maps robot_name -> (world_robot_id, config, trajectory_gen) self._robots: RobotRegistry = {} # Stored generated plan for preview/execute workflow. self._last_plan: GeneratedPlan | None = None + self._last_trajectory: GeneratedTrajectory | None = None + self._motion_speed_scale = 1.0 # Coordinator integration (lazy initialized) self._coordinator_client: RPCClient | None = None @@ -217,6 +242,7 @@ def _initialize_planning(self) -> None: self._world_monitor = planning_specs.world_monitor self._planner = planning_specs.planner self._kinematics = planning_specs.kinematics + self._trajectory_parametrizer = None visualization = create_manipulation_visualization( self.config.visualization, world=world, @@ -234,6 +260,9 @@ def _initialize_planning(self) -> None: self._robots[robot_config.name] = (robot_id, robot_config, traj_gen) self._world_monitor.finalize() + self._trajectory_parametrizer = self._trajectory_parametrizer_for_config( + self.config.trajectory_parametrization + ) # Add floor obstacle to prevent trajectories below the table surface if self.config.floor_z is not None: @@ -419,6 +448,30 @@ def get_error(self) -> str: """ return self._error_message + @rpc + def set_motion_speed(self, speed_scale: float) -> bool: + """Set runtime manipulation motion speed for future generated plans. + + This scale multiplies the configured trajectory parametrization velocity and + acceleration scales. It is captured by the next generated trajectory and does + not mutate an existing plan, an existing generated trajectory, or already- + dispatched controller tasks. Re-plan to apply a changed speed setting. + + Args: + speed_scale: Runtime speed multiplier in the range `(0, 1]`. + Use values below 1.0 for slower, gentler motion. + """ + if speed_scale <= 0.0 or speed_scale > 1.0: + self._error_message = "motion speed scale must be > 0 and <= 1" + return False + self._motion_speed_scale = float(speed_scale) + return True + + @rpc + def get_motion_speed(self) -> float: + """Get the runtime manipulation motion speed scale.""" + return self._motion_speed_scale + @rpc def cancel(self) -> bool: """Cancel current motion or invalidate an in-progress plan.""" @@ -564,8 +617,14 @@ def _joint_target_to_global_names( def _affected_robot_names(self, plan: GeneratedPlan) -> list[RobotName]: """Get stable robot names affected by a generated plan.""" + return self._affected_robot_names_for_group_ids(plan.group_ids) + + def _affected_robot_names_for_group_ids( + self, group_ids: Sequence[PlanningGroupID] + ) -> list[RobotName]: + """Get stable robot names affected by planning group IDs.""" assert self._world_monitor is not None - return list(self._world_monitor.planning_groups.select(plan.group_ids).robot_names) + return list(self._world_monitor.planning_groups.select(tuple(group_ids)).robot_names) def _store_generated_plan( self, group_ids: tuple[PlanningGroupID, ...], result: PlanningResult @@ -580,6 +639,193 @@ def _store_generated_plan( iterations=result.iterations, message=result.message, ) + self._last_trajectory = None + + def _trajectory_config_for_plan(self, plan: GeneratedPlan) -> TrajectoryParametrizationConfig: + """Resolve parametrization config and robot defaults for the selected plan joints.""" + values = self.config.trajectory_parametrization.model_dump() + if plan.path and values.get("velocity_limits") is None: + values["velocity_limits"] = self._velocity_limits_for_plan(plan) + if plan.path and values.get("acceleration_limits") is None: + values["acceleration_limits"] = self._acceleration_limits_for_plan(plan) + return TrajectoryParametrizationConfig(**values) + + def _trajectory_parametrizer_for_config( + self, config: TrajectoryParametrizationConfig + ) -> TrajectoryParametrizerSpec: + """Resolve the configured trajectory parametrizer backend.""" + if config.backend != "roboplan": + return create_trajectory_parametrizer(config) + if self._world_monitor is None: + raise ValueError( + 'trajectory_parametrization.backend="roboplan" requires an initialized world' + ) + world = self._world_monitor.world + if not isinstance(world, TrajectoryParametrizerSpec): + raise ValueError( + 'trajectory_parametrization.backend="roboplan" requires world_backend="roboplan"' + ) + configure = getattr(world, "configure_trajectory_parametrization", None) + if configure is not None: + typed_configure = cast("Callable[[TrajectoryParametrizationConfig], None]", configure) + typed_configure(config) + return world + + def _velocity_limits_for_plan(self, plan: GeneratedPlan) -> list[float]: + """Return per-selected-joint velocity limits in generated-plan joint order.""" + return [self._robot_limit_for_global_joint(name, "velocity") for name in plan.path[0].name] + + def _acceleration_limits_for_plan(self, plan: GeneratedPlan) -> list[float]: + """Return per-selected-joint acceleration limits in generated-plan joint order.""" + return [ + self._robot_limit_for_global_joint(name, "acceleration") for name in plan.path[0].name + ] + + def _robot_limit_for_global_joint(self, global_joint_name: str, limit_kind: str) -> float: + """Resolve a robot model limit for a selected global joint.""" + assert_global_joint_names([global_joint_name]) + robot_name = global_joint_name.split("/", maxsplit=1)[0] + robot = self._robots.get(robot_name) + if robot is None: + raise ValueError(f"No robot registered for joint '{global_joint_name}'") + _, config, _ = robot + local_name = local_joint_name_from_global(robot_name, global_joint_name) + try: + joint_index = config.joint_names.index(local_name) + except ValueError as exc: + raise ValueError(f"Joint '{global_joint_name}' is not in robot config") from exc + if limit_kind == "velocity" and config.velocity_limits is not None: + return float(config.velocity_limits[joint_index]) + if limit_kind == "velocity": + return float(config.max_velocity) + return float(config.max_acceleration) + + def _parametrize_plan(self, plan: GeneratedPlan) -> GeneratedTrajectory: + """Parametrize and store the canonical global generated trajectory.""" + try: + parametrizer = self._trajectory_parametrizer_for_config( + self._trajectory_config_for_plan(plan) + ) + trajectory = parametrizer.parametrize(plan, speed_scale=self._motion_speed_scale) + except ValueError as exc: + trajectory = GeneratedTrajectory( + status=ParametrizationStatus.INVALID_PLAN, + message=str(exc), + speed_scale=self._motion_speed_scale, + source_group_ids=plan.group_ids, + source_plan_status=plan.status, + source_plan_message=plan.message, + ) + if plan is self._last_plan: + self._last_trajectory = trajectory + return trajectory + + def _trajectory_for_plan(self, plan: GeneratedPlan) -> GeneratedTrajectory: + """Return the stored trajectory for `_last_plan` or parametrize an explicit plan.""" + if plan is self._last_plan and self._last_trajectory is not None: + return self._last_trajectory + return self._parametrize_plan(plan) + + def _prepare_trajectory_dispatch(self, trajectory: GeneratedTrajectory) -> TrajectoryDispatch: + """Project a global generated trajectory into per-task dispatch messages.""" + if not trajectory.is_success(): + return TrajectoryDispatch( + status=TrajectoryDispatchStatus.INVALID_TRAJECTORY, + message=f"GeneratedTrajectory is not successful: {trajectory.message}", + ) + if not trajectory.points or not trajectory.joint_names: + return TrajectoryDispatch( + status=TrajectoryDispatchStatus.INVALID_TRAJECTORY, + message="GeneratedTrajectory has no timed points", + ) + try: + assert_global_joint_names(trajectory.joint_names) + affected = self._affected_robot_names_for_group_ids(trajectory.source_group_ids) + except Exception as exc: + return TrajectoryDispatch( + status=TrajectoryDispatchStatus.INVALID_TRAJECTORY, + message=f"Failed to resolve generated trajectory: {exc}", + ) + assert self._world_monitor is not None + + trajectories_by_task: dict[str, JointTrajectory] = {} + robot_names_by_task: dict[str, RobotName] = {} + for name in affected: + robot = self._get_robot(name) + if robot is None: + return TrajectoryDispatch( + status=TrajectoryDispatchStatus.FAILED, + message=f"Robot '{name}' is not registered", + ) + resolved_name, robot_id, config, _ = robot + task_name = config.coordinator_task_name + if not task_name: + return TrajectoryDispatch( + status=TrajectoryDispatchStatus.MISSING_TASK, + message=f"No coordinator_task_name for '{resolved_name}'", + ) + + current = self._world_monitor.get_current_joint_state(robot_id) + current_by_name = ( + dict(zip(current.name, current.position, strict=False)) + if current is not None + else {} + ) + global_joint_names = make_global_joint_names(resolved_name, config.joint_names) + selected_indices = { + joint_name: index for index, joint_name in enumerate(trajectory.joint_names) + } + points = [] + for point in trajectory.points: + if len(point.positions) != len(trajectory.joint_names): + return TrajectoryDispatch( + status=TrajectoryDispatchStatus.INVALID_TRAJECTORY, + message="GeneratedTrajectory point positions do not match joint_names", + ) + positions: list[float] = [] + velocities: list[float] = [] + for local_name, global_joint_name in zip( + config.joint_names, global_joint_names, strict=True + ): + selected_index = selected_indices.get(global_joint_name) + if selected_index is not None: + positions.append(float(point.positions[selected_index])) + velocity = ( + float(point.velocities[selected_index]) + if len(point.velocities) == len(trajectory.joint_names) + else 0.0 + ) + velocities.append(velocity) + elif local_name in current_by_name: + positions.append(float(current_by_name[local_name])) + velocities.append(0.0) + else: + return TrajectoryDispatch( + status=TrajectoryDispatchStatus.MISSING_JOINT, + message=( + f"Cannot dispatch trajectory for '{resolved_name}': " + f"missing joint '{global_joint_name}'" + ), + ) + points.append( + TrajectoryPoint( + time_from_start=point.time_from_start, + positions=positions, + velocities=velocities, + ) + ) + trajectories_by_task[task_name] = JointTrajectory( + joint_names=list(global_joint_names), + points=points, + ) + robot_names_by_task[task_name] = resolved_name + + return TrajectoryDispatch( + trajectories_by_task=trajectories_by_task, + robot_names_by_task=robot_names_by_task, + status=TrajectoryDispatchStatus.SUCCESS, + message="Trajectory dispatch prepared", + ) def _plan_selected_path( self, group_ids: tuple[PlanningGroupID, ...], start: JointState, goal: JointState @@ -604,6 +850,10 @@ def _plan_selected_path( path_joints, ) self._store_generated_plan(group_ids, result) + assert self._last_plan is not None + trajectory = self._parametrize_plan(self._last_plan) + if not trajectory.is_success(): + return self._fail(f"Trajectory parametrization failed: {trajectory.message}") self._state = ManipulationState.COMPLETED return True @@ -962,8 +1212,26 @@ def preview_plan( if robot_name is not None and robot_name not in self._affected_robot_names(plan): logger.error("Generated plan does not affect robot '%s'", robot_name) return False - animation_duration = duration if duration is not None else 1.0 - self._world_monitor.animate_plan(plan, animation_duration) + trajectory = self._trajectory_for_plan(plan) + if not trajectory.is_success(): + logger.error( + "Cannot preview plan: trajectory parametrization failed: %s", trajectory.message + ) + return False + animation_duration = duration if duration is not None else trajectory.duration + preview_plan = GeneratedPlan( + group_ids=trajectory.source_group_ids or plan.group_ids, + path=[ + JointState(name=list(trajectory.joint_names), position=list(point.positions)) + for point in trajectory.points + ], + status=trajectory.source_plan_status, + planning_time=plan.planning_time, + path_length=plan.path_length, + iterations=plan.iterations, + message=trajectory.source_plan_message or plan.message, + ) + self._world_monitor.animate_plan(preview_plan, animation_duration) return True @rpc @@ -994,6 +1262,7 @@ def clear_planned_path(self) -> bool: True if cleared """ self._last_plan = None + self._last_trajectory = None return True @rpc @@ -1195,10 +1464,7 @@ def execute(self) -> bool: @rpc def execute_plan(self, plan: GeneratedPlan | None = None) -> bool: - """Project and execute a generated plan through affected trajectory tasks. - - TODO: proper time parametrization. - """ + """Execute a generated plan through its canonical generated trajectory.""" plan = plan or self._last_plan if plan is None or not plan.path: logger.warning("No generated plan") @@ -1207,93 +1473,32 @@ def execute_plan(self, plan: GeneratedPlan | None = None) -> bool: logger.error("No coordinator client") return False - try: - affected = self._affected_robot_names(plan) - except Exception as exc: - return self._fail(f"Failed to resolve generated plan: {exc}") + trajectory = self._trajectory_for_plan(plan) + if not trajectory.is_success(): + return self._fail(f"Trajectory parametrization failed: {trajectory.message}") + dispatch = self._prepare_trajectory_dispatch(trajectory) + if not dispatch.is_success(): + return self._fail(f"Trajectory dispatch failed: {dispatch.message}") logger.info( "Execute plan: groups=%s, affected=%s", plan.group_ids, - affected, + list(dispatch.robot_names_by_task.values()), ) - assert self._world_monitor is not None - - dispatches: list[tuple[RobotName, str, RobotModelConfig, JointTrajectory]] = [] - for name in affected: - robot = self._get_robot(name) - if robot is None: - return False - resolved_name, robot_id, config, traj_gen = robot - task_name = config.coordinator_task_name - if not task_name: - logger.error(f"No coordinator_task_name for '{resolved_name}'") - return False - - current = self._world_monitor.get_current_joint_state(robot_id) - current_by_name = ( - dict(zip(current.name, current.position, strict=False)) - if current is not None - else {} - ) - - global_joint_names = make_global_joint_names(resolved_name, config.joint_names) - local_path: list[JointState] = [] - for waypoint in plan.path: - if len(waypoint.name) != len(waypoint.position): - logger.error( - "Cannot execute plan for '%s': waypoint has %d names but %d positions", - resolved_name, - len(waypoint.name), - len(waypoint.position), - ) - return False - try: - assert_global_joint_names(waypoint.name) - except ValueError as exc: - logger.error("Cannot execute plan for '%s': %s", resolved_name, exc) - return False - selected_positions = dict(zip(waypoint.name, waypoint.position, strict=True)) - positions: list[float] = [] - for local_name, global_name in zip( - config.joint_names, global_joint_names, strict=True - ): - if global_name in selected_positions: - positions.append(selected_positions[global_name]) - elif local_name in current_by_name: - positions.append(current_by_name[local_name]) - else: - logger.error( - "Cannot execute plan for '%s': missing joint '%s'", - resolved_name, - global_name, - ) - return False - local_path.append(JointState(name=list(config.joint_names), position=positions)) - if len(local_path) < 2: - logger.error("Plan projection for '%s' has fewer than two waypoints", resolved_name) - return False - local_trajectory = traj_gen.generate([list(state.position) for state in local_path]) - trajectory = JointTrajectory( - joint_names=list(global_joint_names), - points=local_trajectory.points, - timestamp=local_trajectory.timestamp, - ) - dispatches.append((resolved_name, task_name, config, trajectory)) self._state = ManipulationState.EXECUTING - for _name, task_name, config, trajectory in dispatches: + for task_name, task_trajectory in dispatch.trajectories_by_task.items(): logger.info( "Executing: task='%s', %d pts, %.2fs", task_name, - len(trajectory.points), - trajectory.duration, + len(task_trajectory.points), + task_trajectory.duration, ) try: result = self._invoke_coordinator_task( client, task_name, "execute", - {"trajectory": trajectory}, + {"trajectory": task_trajectory}, ) except TimeoutError as exc: return self._fail(f"Coordinator RPC timed out for task '{task_name}': {exc}") @@ -1301,7 +1506,7 @@ def execute_plan(self, plan: GeneratedPlan | None = None) -> bool: return self._fail(f"Coordinator RPC failed for task '{task_name}': {exc}") logger.info( "Coordinator execute result: task='%s', result=%r", - config.coordinator_task_name, + task_name, result, ) if not result: @@ -1542,13 +1747,14 @@ def _lift_if_low( return self._preview_execute_wait(robot_name) def _preview_execute_wait( - self, robot_name: RobotName | None = None, preview_duration: float = 0.5 + self, robot_name: RobotName | None = None, preview_duration: float | None = None ) -> SkillResult[ManipulationSkillError]: """Preview planned path, execute, and wait for completion. Args: robot_name: Robot to operate on - preview_duration: Duration to animate the preview in Meshcat (seconds) + preview_duration: Optional override for preview animation duration. When unset, + preview uses the generated trajectory duration. """ logger.info("Previewing trajectory...") self.preview_plan(duration=preview_duration, robot_name=robot_name) diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index 686fb652ac..d18ef4e00a 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -17,6 +17,7 @@ from __future__ import annotations from pathlib import Path +from typing import Literal, TypeAlias from pydantic import Field @@ -29,6 +30,32 @@ from dimos.manipulation.planning.groups.models import PlanningGroupDefinition from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +TrajectoryParametrizationBackend: TypeAlias = Literal["simple_trapezoid", "roboplan"] +RoboPlanSplineFittingMode: TypeAlias = Literal["hermite", "cubic", "adaptive", "linear_blend"] + + +class TrajectoryParametrizationConfig(ModuleConfig): + """Configuration for manipulation trajectory parametrization. + + The default `simple_trapezoid` backend preserves the historical baseline + timing behavior. RoboPlan trajectory parametrization is explicit opt-in and + runs through the RoboPlan world so the scene, planning group name, and native + joint mapping stay backend-owned. + """ + + backend: TrajectoryParametrizationBackend = "simple_trapezoid" + velocity_scale: float = Field(default=1.0, gt=0.0) + acceleration_scale: float = Field(default=1.0, gt=0.0) + velocity_limits: list[float] | None = None + acceleration_limits: list[float] | None = None + minimum_segment_duration: float | None = Field(default=None, gt=0.0) + simple_points_per_segment: int = Field(default=50, ge=1) + roboplan_dt: float = Field(default=0.01, gt=0.0) + roboplan_spline_mode: RoboPlanSplineFittingMode = "hermite" + roboplan_max_adaptive_iterations: int = Field(default=10, ge=1) + roboplan_max_adaptive_step_size: float = Field(default=0.05, gt=0.0) + roboplan_max_blend_deviation: float = Field(default=0.01, gt=0.0) + class RobotModelConfig(ModuleConfig): """Configuration for adding a robot to the world. diff --git a/dimos/manipulation/planning/spec/enums.py b/dimos/manipulation/planning/spec/enums.py index e1c7c1a735..5e4ba3f4b4 100644 --- a/dimos/manipulation/planning/spec/enums.py +++ b/dimos/manipulation/planning/spec/enums.py @@ -48,3 +48,23 @@ class PlanningStatus(Enum): COLLISION_AT_START = auto() COLLISION_AT_GOAL = auto() UNSUPPORTED = auto() + + +class ParametrizationStatus(Enum): + """Status of trajectory parametrization.""" + + SUCCESS = auto() + INVALID_PLAN = auto() + INFEASIBLE = auto() + BACKEND_UNAVAILABLE = auto() + FAILED = auto() + + +class TrajectoryDispatchStatus(Enum): + """Status of trajectory dispatch preparation.""" + + SUCCESS = auto() + INVALID_TRAJECTORY = auto() + MISSING_TASK = auto() + MISSING_JOINT = auto() + FAILED = auto() diff --git a/dimos/manipulation/planning/spec/models.py b/dimos/manipulation/planning/spec/models.py index f9f87e3398..e92cddbd2b 100644 --- a/dimos/manipulation/planning/spec/models.py +++ b/dimos/manipulation/planning/spec/models.py @@ -23,7 +23,9 @@ from dimos.manipulation.planning.spec.enums import ( IKStatus, ObstacleType, + ParametrizationStatus, PlanningStatus, + TrajectoryDispatchStatus, ) if TYPE_CHECKING: @@ -33,6 +35,8 @@ from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState + from dimos.msgs.trajectory_msgs.JointTrajectory import JointTrajectory + from dimos.msgs.trajectory_msgs.TrajectoryPoint import TrajectoryPoint RobotName: TypeAlias = str @@ -126,6 +130,49 @@ def is_success(self) -> bool: return self.status == PlanningStatus.SUCCESS +@dataclass +class GeneratedTrajectory: + """Canonical global time-parametrized manipulation artifact. + + The trajectory uses global joint names, a single shared `time_from_start` + domain across all joints, and status that is independent from the source + geometric `GeneratedPlan.status`. + """ + + joint_names: list[GlobalJointName] = field(default_factory=list) + points: list[TrajectoryPoint] = field(default_factory=list) + duration: float = 0.0 + speed_scale: float = 1.0 + status: ParametrizationStatus = ParametrizationStatus.FAILED + message: str = "" + source_group_ids: tuple[PlanningGroupID, ...] = () + source_plan_status: PlanningStatus = PlanningStatus.NO_SOLUTION + source_plan_message: str = "" + + def is_success(self) -> bool: + """Check if trajectory parametrization was successful.""" + return self.status == ParametrizationStatus.SUCCESS + + +@dataclass +class TrajectoryDispatch: + """Execution-preparation artifact derived from `GeneratedTrajectory`. + + `trajectories_by_task` contains coordinator-task-specific messages. These + messages preserve the generated trajectory timing instead of retiming each + task projection independently. + """ + + trajectories_by_task: dict[str, JointTrajectory] = field(default_factory=dict) + robot_names_by_task: dict[str, RobotName] = field(default_factory=dict) + status: TrajectoryDispatchStatus = TrajectoryDispatchStatus.FAILED + message: str = "" + + def is_success(self) -> bool: + """Check if dispatch preparation was successful.""" + return self.status == TrajectoryDispatchStatus.SUCCESS + + @dataclass class Obstacle: """Obstacle specification for collision avoidance. diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index 3c0dca3cf5..bb2169bc08 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -22,6 +22,8 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from dimos.spec.utils import Spec + if TYPE_CHECKING: from collections.abc import Sequence from contextlib import AbstractContextManager @@ -33,6 +35,7 @@ from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.models import ( GeneratedPlan, + GeneratedTrajectory, IKResult, Obstacle, PlanningGroupID, @@ -294,3 +297,27 @@ def plan_selected_joint_path( def get_name(self) -> str: """Get planner name.""" ... + + +@runtime_checkable +class TrajectoryParametrizerSpec(Spec, Protocol): + """Protocol for converting a geometric plan into a timed global trajectory.""" + + def parametrize( + self, + plan: GeneratedPlan, + *, + speed_scale: float = 1.0, + ) -> GeneratedTrajectory: + """Parametrize a successful geometric generated plan. + + Args: + plan: Geometric plan to time-parametrize. + speed_scale: Runtime speed multiplier applied to velocity and acceleration + policy for this parametrization only. + """ + ... + + def get_name(self) -> str: + """Get trajectory parametrizer backend name.""" + ... diff --git a/dimos/manipulation/planning/trajectory_generator/joint_trajectory_generator.py b/dimos/manipulation/planning/trajectory_generator/joint_trajectory_generator.py index 1ac6b74351..0f7617cdc2 100644 --- a/dimos/manipulation/planning/trajectory_generator/joint_trajectory_generator.py +++ b/dimos/manipulation/planning/trajectory_generator/joint_trajectory_generator.py @@ -58,6 +58,7 @@ def __init__( max_velocity: list[float] | float = 1.0, max_acceleration: list[float] | float = 2.0, points_per_segment: int = 50, + minimum_segment_duration: float = 0.01, ) -> None: """ Initialize trajectory generator. @@ -70,6 +71,7 @@ def __init__( """ self.num_joints = num_joints self.points_per_segment = points_per_segment + self.minimum_segment_duration = minimum_segment_duration # Initialize limits self.max_velocity: list[float] = [] @@ -169,7 +171,7 @@ def _generate_segment( segment_duration = max(segment_duration, t) # Ensure minimum duration - segment_duration = max(segment_duration, 0.01) + segment_duration = max(segment_duration, self.minimum_segment_duration) # Generate points along the segment points: list[TrajectoryPoint] = [] diff --git a/dimos/manipulation/planning/trajectory_generator/parametrizers.py b/dimos/manipulation/planning/trajectory_generator/parametrizers.py new file mode 100644 index 0000000000..0c80283666 --- /dev/null +++ b/dimos/manipulation/planning/trajectory_generator/parametrizers.py @@ -0,0 +1,190 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Trajectory parametrization backends for manipulation planning.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dimos.manipulation.planning.spec.config import TrajectoryParametrizationConfig +from dimos.manipulation.planning.spec.enums import ParametrizationStatus +from dimos.manipulation.planning.spec.models import GeneratedPlan, GeneratedTrajectory +from dimos.manipulation.planning.trajectory_generator.joint_trajectory_generator import ( + JointTrajectoryGenerator, +) +from dimos.msgs.trajectory_msgs.JointTrajectory import JointTrajectory + + +def _failure( + plan: GeneratedPlan, + status: ParametrizationStatus, + message: str, + *, + speed_scale: float = 1.0, +) -> GeneratedTrajectory: + return GeneratedTrajectory( + status=status, + message=message, + speed_scale=speed_scale, + source_group_ids=plan.group_ids, + source_plan_status=plan.status, + source_plan_message=plan.message, + ) + + +def _plan_joint_names(plan: GeneratedPlan) -> list[str]: + if not plan.path: + return [] + return list(plan.path[0].name) + + +def _plan_waypoints(plan: GeneratedPlan) -> list[list[float]]: + joint_names = _plan_joint_names(plan) + waypoints: list[list[float]] = [] + for waypoint in plan.path: + if list(waypoint.name) != joint_names: + raise ValueError("All generated-plan waypoints must share identical joint ordering") + if len(waypoint.position) != len(joint_names): + raise ValueError("Generated-plan waypoint name and position lengths must match") + waypoints.append([float(position) for position in waypoint.position]) + return waypoints + + +def _limits( + configured: list[float] | None, + scale: float, + num_joints: int, + default_limit: float, +) -> list[float]: + raw_limits = configured if configured is not None else [default_limit] * num_joints + if len(raw_limits) != num_joints: + raise ValueError(f"Expected {num_joints} limits, got {len(raw_limits)}") + limits = [float(limit) * scale for limit in raw_limits] + if any(limit <= 0.0 for limit in limits): + raise ValueError("Trajectory parametrization limits must be positive") + return limits + + +def _trajectory_from_joint_trajectory( + plan: GeneratedPlan, + joint_names: list[str], + trajectory: JointTrajectory, + message: str, + *, + speed_scale: float, +) -> GeneratedTrajectory: + return GeneratedTrajectory( + joint_names=joint_names, + points=list(trajectory.points), + duration=trajectory.duration, + speed_scale=speed_scale, + status=ParametrizationStatus.SUCCESS, + message=message, + source_group_ids=plan.group_ids, + source_plan_status=plan.status, + source_plan_message=plan.message, + ) + + +@dataclass(frozen=True) +class SimpleTrapezoidParametrizer: + """Compatibility parametrizer wrapping the existing trapezoidal generator.""" + + config: TrajectoryParametrizationConfig + + def get_name(self) -> str: + """Get trajectory parametrizer backend name.""" + return "simple_trapezoid" + + def parametrize( + self, + plan: GeneratedPlan, + *, + speed_scale: float = 1.0, + ) -> GeneratedTrajectory: + """Parametrize a geometric plan with the simple trapezoidal backend.""" + if not plan.is_success(): + return _failure( + plan, + ParametrizationStatus.INVALID_PLAN, + "GeneratedPlan is not successful", + speed_scale=speed_scale, + ) + if speed_scale <= 0.0: + return _failure( + plan, + ParametrizationStatus.INVALID_PLAN, + "speed_scale must be positive", + speed_scale=speed_scale, + ) + if len(plan.path) < 2: + return _failure( + plan, + ParametrizationStatus.INVALID_PLAN, + "GeneratedPlan must contain at least two waypoints", + speed_scale=speed_scale, + ) + try: + joint_names = _plan_joint_names(plan) + waypoints = _plan_waypoints(plan) + generator = JointTrajectoryGenerator( + num_joints=len(joint_names), + max_velocity=_limits( + self.config.velocity_limits, + self.config.velocity_scale * speed_scale, + len(joint_names), + 1.0, + ), + max_acceleration=_limits( + self.config.acceleration_limits, + self.config.acceleration_scale * speed_scale, + len(joint_names), + 2.0, + ), + points_per_segment=self.config.simple_points_per_segment, + minimum_segment_duration=self.config.minimum_segment_duration or 0.01, + ) + trajectory = generator.generate(waypoints) + trajectory.joint_names = list(joint_names) + trajectory.num_joints = len(joint_names) + except ValueError as exc: + return _failure( + plan, + ParametrizationStatus.INVALID_PLAN, + str(exc), + speed_scale=speed_scale, + ) + return _trajectory_from_joint_trajectory( + plan, + joint_names, + trajectory, + "Trajectory parametrized with simple_trapezoid", + speed_scale=speed_scale, + ) + + +def create_trajectory_parametrizer( + config: TrajectoryParametrizationConfig | None = None, +) -> SimpleTrapezoidParametrizer: + """Create a trajectory parametrizer backend from config.""" + resolved_config = config or TrajectoryParametrizationConfig() + if resolved_config.backend == "simple_trapezoid": + return SimpleTrapezoidParametrizer(resolved_config) + if resolved_config.backend == "roboplan": + raise ValueError( + 'trajectory_parametrization.backend="roboplan" is provided by RoboPlanWorld; ' + 'select world_backend="roboplan" or use backend="simple_trapezoid"' + ) + raise ValueError(f"Unknown trajectory parametrization backend: {resolved_config.backend}") diff --git a/dimos/manipulation/planning/trajectory_generator/test_parametrizers.py b/dimos/manipulation/planning/trajectory_generator/test_parametrizers.py new file mode 100644 index 0000000000..ed3d6e3c42 --- /dev/null +++ b/dimos/manipulation/planning/trajectory_generator/test_parametrizers.py @@ -0,0 +1,149 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for manipulation trajectory parametrization artifacts and backends.""" + +from __future__ import annotations + +import pytest + +from dimos.manipulation.planning.spec.config import TrajectoryParametrizationConfig +from dimos.manipulation.planning.spec.enums import ParametrizationStatus, PlanningStatus +from dimos.manipulation.planning.spec.models import GeneratedPlan, GeneratedTrajectory +from dimos.manipulation.planning.trajectory_generator.parametrizers import ( + SimpleTrapezoidParametrizer, + create_trajectory_parametrizer, +) +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.msgs.trajectory_msgs.TrajectoryPoint import TrajectoryPoint + + +def _successful_plan() -> GeneratedPlan: + return GeneratedPlan( + group_ids=("arm/manipulator",), + path=[ + JointState({"name": ["arm/joint_b", "arm/joint_a"], "position": [0.0, 1.0]}), + JointState({"name": ["arm/joint_b", "arm/joint_a"], "position": [0.5, 0.25]}), + JointState({"name": ["arm/joint_b", "arm/joint_a"], "position": [1.0, -0.5]}), + ], + status=PlanningStatus.SUCCESS, + planning_time=0.25, + path_length=1.5, + iterations=7, + message="planned", + ) + + +def test_generated_plan_is_geometric_and_generated_trajectory_carries_metadata() -> None: + plan = _successful_plan() + trajectory = GeneratedTrajectory( + joint_names=["arm/joint_b", "arm/joint_a"], + points=[TrajectoryPoint(time_from_start=0.0, positions=[0.0, 1.0])], + duration=1.25, + status=ParametrizationStatus.SUCCESS, + message="timed", + source_group_ids=plan.group_ids, + source_plan_status=plan.status, + source_plan_message=plan.message, + ) + + assert not hasattr(plan, "duration") + assert not hasattr(plan, "joint_names") + assert trajectory.is_success() + assert trajectory.duration == pytest.approx(1.25) + assert trajectory.speed_scale == pytest.approx(1.0) + assert trajectory.source_group_ids == plan.group_ids + assert trajectory.source_plan_status == PlanningStatus.SUCCESS + assert trajectory.source_plan_message == "planned" + + +def test_unsuccessful_plan_parametrization_fails_without_changing_plan_status() -> None: + plan = GeneratedPlan( + group_ids=("arm/manipulator",), + status=PlanningStatus.TIMEOUT, + message="planner timed out", + ) + + trajectory = SimpleTrapezoidParametrizer(TrajectoryParametrizationConfig()).parametrize(plan) + + assert plan.status == PlanningStatus.TIMEOUT + assert trajectory.status == ParametrizationStatus.INVALID_PLAN + assert trajectory.source_plan_status == PlanningStatus.TIMEOUT + assert trajectory.source_plan_message == "planner timed out" + assert "not successful" in trajectory.message + + +def test_simple_trapezoid_preserves_joint_ordering_and_shared_monotonic_time_domain() -> None: + plan = _successful_plan() + trajectory = SimpleTrapezoidParametrizer( + TrajectoryParametrizationConfig(simple_points_per_segment=4) + ).parametrize(plan) + + assert trajectory.status == ParametrizationStatus.SUCCESS + assert trajectory.joint_names == ["arm/joint_b", "arm/joint_a"] + times = [point.time_from_start for point in trajectory.points] + assert times == sorted(times) + assert len(set(times)) == len(trajectory.points) + assert trajectory.duration == pytest.approx(times[-1]) + assert trajectory.points[-1].positions == pytest.approx([1.0, -0.5]) + assert all(len(point.positions) == len(trajectory.joint_names) for point in trajectory.points) + + +def test_simple_trapezoid_speed_scale_slows_timing_when_reduced() -> None: + plan = _successful_plan() + parametrizer = SimpleTrapezoidParametrizer(TrajectoryParametrizationConfig()) + + default_trajectory = parametrizer.parametrize(plan) + slowed_trajectory = parametrizer.parametrize(plan, speed_scale=0.5) + + assert default_trajectory.status == ParametrizationStatus.SUCCESS + assert slowed_trajectory.status == ParametrizationStatus.SUCCESS + assert default_trajectory.speed_scale == pytest.approx(1.0) + assert slowed_trajectory.speed_scale == pytest.approx(0.5) + assert slowed_trajectory.duration > default_trajectory.duration + assert slowed_trajectory.points[-1].positions == pytest.approx( + default_trajectory.points[-1].positions + ) + + +def test_parametrizer_factory_defaults_to_simple_trapezoid() -> None: + parametrizer = create_trajectory_parametrizer() + + assert isinstance(parametrizer, SimpleTrapezoidParametrizer) + assert parametrizer.get_name() == "simple_trapezoid" + + +@pytest.mark.parametrize( + "kwargs", + [ + {"velocity_scale": 0.0}, + {"acceleration_scale": 0.0}, + {"minimum_segment_duration": 0.0}, + {"simple_points_per_segment": 0}, + {"roboplan_dt": 0.0}, + {"roboplan_max_adaptive_iterations": 0}, + {"roboplan_max_adaptive_step_size": 0.0}, + {"roboplan_max_blend_deviation": 0.0}, + ], +) +def test_trajectory_parametrization_config_rejects_non_positive_controls( + kwargs: dict[str, float | int], +) -> None: + with pytest.raises(ValueError): + TrajectoryParametrizationConfig(**kwargs) + + +def test_roboplan_factory_reports_world_owned_backend() -> None: + with pytest.raises(ValueError, match='world_backend="roboplan"'): + create_trajectory_parametrizer(TrajectoryParametrizationConfig(backend="roboplan")) diff --git a/dimos/manipulation/planning/world/roboplan_world.py b/dimos/manipulation/planning/world/roboplan_world.py index a2739afc99..7613bef958 100644 --- a/dimos/manipulation/planning/world/roboplan_world.py +++ b/dimos/manipulation/planning/world/roboplan_world.py @@ -25,6 +25,7 @@ from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass, field, replace +import importlib from itertools import combinations, pairwise from math import atan2, sqrt from pathlib import Path @@ -47,13 +48,27 @@ from dimos.manipulation.planning.groups.registry import PlanningGroupRegistry from dimos.manipulation.planning.groups.utils import joint_target_to_global_names -from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.manipulation.planning.spec.enums import ObstacleType, PlanningStatus -from dimos.manipulation.planning.spec.models import Obstacle, PlanningResult, WorldRobotID +from dimos.manipulation.planning.spec.config import ( + RobotModelConfig, + TrajectoryParametrizationConfig, +) +from dimos.manipulation.planning.spec.enums import ( + ObstacleType, + ParametrizationStatus, + PlanningStatus, +) +from dimos.manipulation.planning.spec.models import ( + GeneratedPlan, + GeneratedTrajectory, + Obstacle, + PlanningResult, + WorldRobotID, +) from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake from dimos.manipulation.planning.utils.path_utils import compute_path_length from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.msgs.trajectory_msgs.TrajectoryPoint import TrajectoryPoint from dimos.utils.logging_config import setup_logger from dimos.utils.transform_utils import matrix_to_pose, pose_to_matrix @@ -136,6 +151,9 @@ def __init__( self._enable_viz = enable_viz self._max_generated_composite_groups = max_generated_composite_groups self._selected_start_tolerance = selected_start_tolerance + self._trajectory_parametrization_config = TrajectoryParametrizationConfig( + backend="roboplan" + ) if enable_viz: logger.warning("RoboPlanWorld does not currently provide manipulation visualization") @@ -492,11 +510,214 @@ def plan_selected_joint_path( ) def get_name(self) -> str: - """Get planner name.""" - return "RoboPlan" + """Get backend name.""" + return "roboplan" + + def configure_trajectory_parametrization(self, config: TrajectoryParametrizationConfig) -> None: + """Configure RoboPlan-backed trajectory parametrization.""" + if config.backend != "roboplan": + raise ValueError("RoboPlanWorld only supports backend='roboplan'") + self._trajectory_parametrization_config = config + + def parametrize( + self, + plan: GeneratedPlan, + *, + speed_scale: float = 1.0, + ) -> GeneratedTrajectory: + """Time-parametrize a geometric generated plan using RoboPlan TOPP-RA.""" + if not plan.is_success(): + return self._trajectory_parametrization_failure( + plan, + ParametrizationStatus.INVALID_PLAN, + "GeneratedPlan is not successful", + speed_scale=speed_scale, + ) + if speed_scale <= 0.0: + return self._trajectory_parametrization_failure( + plan, + ParametrizationStatus.INVALID_PLAN, + "speed_scale must be positive", + speed_scale=speed_scale, + ) + if len(plan.path) < 2: + return self._trajectory_parametrization_failure( + plan, + ParametrizationStatus.INVALID_PLAN, + "GeneratedPlan must contain at least two waypoints", + speed_scale=speed_scale, + ) + try: + self._require_finalized() + roboplan_toppra = importlib.import_module("roboplan.toppra") + group_data = self._group_data_for_selection_ids(plan.group_ids) + joint_path = self._generated_plan_to_roboplan_joint_path(plan, group_data) + options = self._roboplan_toppra_options(roboplan_toppra, speed_scale=speed_scale) + self._set_scene_current_q(self._live_context) + native_trajectory = roboplan_toppra.PathParameterizerTOPPRA( + self._require_scene(), group_data.group_name + ).generate(joint_path, options) + return self._roboplan_trajectory_to_generated( + plan, + group_data, + native_trajectory, + speed_scale=speed_scale, + ) + except ImportError: + return self._trajectory_parametrization_failure( + plan, + ParametrizationStatus.BACKEND_UNAVAILABLE, + "RoboPlan trajectory parametrization requires roboplan.toppra. " + "Install the manipulation extra before selecting backend='roboplan'.", + speed_scale=speed_scale, + ) + except ValueError as exc: + return self._trajectory_parametrization_failure( + plan, + ParametrizationStatus.INVALID_PLAN, + str(exc), + speed_scale=speed_scale, + ) + except Exception as exc: + return self._trajectory_parametrization_failure( + plan, + ParametrizationStatus.FAILED, + f"RoboPlan trajectory parametrization failed: {exc}", + speed_scale=speed_scale, + ) # Internals + def _trajectory_parametrization_failure( + self, + plan: GeneratedPlan, + status: ParametrizationStatus, + message: str, + *, + speed_scale: float = 1.0, + ) -> GeneratedTrajectory: + return GeneratedTrajectory( + status=status, + message=message, + speed_scale=speed_scale, + source_group_ids=plan.group_ids, + source_plan_status=plan.status, + source_plan_message=plan.message, + ) + + def _generated_plan_to_roboplan_joint_path( + self, + plan: GeneratedPlan, + group_data: _RoboPlanGroupData, + ) -> Any: + expected_global_names = [ + group_data.native_to_global_joint_name[native_name] + for native_name in group_data.native_joint_names + ] + positions: list[list[float]] = [] + for waypoint in plan.path: + if len(waypoint.name) != len(waypoint.position): + raise ValueError("Generated-plan waypoint name and position lengths must match") + positions_by_name = { + name: float(position) + for name, position in zip(waypoint.name, waypoint.position, strict=True) + } + try: + positions.append([positions_by_name[name] for name in expected_global_names]) + except KeyError as exc: + raise ValueError( + f"GeneratedPlan waypoint is missing selected joint '{exc.args[0]}'" + ) from exc + joint_path = roboplan_core.JointPath() + joint_path.joint_names = list(group_data.native_joint_names) + joint_path.positions = np.asarray(positions, dtype=np.float64) + return joint_path + + def _roboplan_toppra_options(self, roboplan_toppra: Any, *, speed_scale: float) -> Any: + config = self._trajectory_parametrization_config + options = roboplan_toppra.TOPPRAOptions() + mode_name_by_config = { + "hermite": "Hermite", + "cubic": "Cubic", + "adaptive": "Adaptive", + "linear_blend": "LinearBlend", + } + options.dt = config.roboplan_dt + options.mode = getattr( + roboplan_toppra.SplineFittingMode, + mode_name_by_config[config.roboplan_spline_mode], + ) + options.velocity_scale = config.velocity_scale * speed_scale + options.acceleration_scale = config.acceleration_scale * speed_scale + options.max_adaptive_iterations = config.roboplan_max_adaptive_iterations + options.max_adaptive_step_size = config.roboplan_max_adaptive_step_size + options.max_blend_deviation = config.roboplan_max_blend_deviation + return options + + def _roboplan_trajectory_to_generated( + self, + plan: GeneratedPlan, + group_data: _RoboPlanGroupData, + native_trajectory: Any, + *, + speed_scale: float, + ) -> GeneratedTrajectory: + native_joint_names = tuple( + getattr(native_trajectory, "joint_names", None) or group_data.native_joint_names + ) + try: + global_joint_names = [ + group_data.native_to_global_joint_name[native_name] + for native_name in native_joint_names + ] + except KeyError as exc: + raise ValueError( + f"RoboPlan trajectory returned unknown native joint '{exc.args[0]}'" + ) from exc + + times = np.asarray(getattr(native_trajectory, "times", []), dtype=np.float64) + positions = np.asarray(getattr(native_trajectory, "positions", []), dtype=np.float64) + velocities_raw = getattr(native_trajectory, "velocities", None) + velocities = ( + np.zeros_like(positions) + if velocities_raw is None or len(velocities_raw) == 0 + else np.asarray(velocities_raw, dtype=np.float64) + ) + if positions.ndim != 2: + raise ValueError("RoboPlan trajectory positions must be a 2D array") + if len(times) != positions.shape[0]: + raise ValueError("RoboPlan trajectory times must align with positions") + if positions.shape[1] != len(global_joint_names): + raise ValueError("RoboPlan trajectory joint count does not match joint_names") + if velocities.shape != positions.shape: + raise ValueError("RoboPlan trajectory velocities must align with positions") + if len(times) == 0 or np.any(~np.isfinite(times)) or np.any(np.diff(times) < 0.0): + raise ValueError("RoboPlan trajectory times must be finite and monotonic") + if np.any(~np.isfinite(positions)) or np.any(~np.isfinite(velocities)): + raise ValueError("RoboPlan trajectory contains non-finite values") + + points = [ + TrajectoryPoint( + time_from_start=float(time_from_start), + positions=[float(value) for value in position], + velocities=[float(value) for value in velocity], + ) + for time_from_start, position, velocity in zip( + times, positions, velocities, strict=True + ) + ] + return GeneratedTrajectory( + joint_names=global_joint_names, + points=points, + duration=float(times[-1]), + speed_scale=speed_scale, + status=ParametrizationStatus.SUCCESS, + message="Trajectory parametrized with roboplan", + source_group_ids=plan.group_ids, + source_plan_status=plan.status, + source_plan_message=plan.message, + ) + def _create_scene(self) -> Any: if self._uses_composite_model: urdf_path, srdf_path, package_paths = self._prepare_composite_model() diff --git a/dimos/manipulation/test_agentic_manipulation_module.py b/dimos/manipulation/test_agentic_manipulation_module.py index 335093202b..5670d247aa 100644 --- a/dimos/manipulation/test_agentic_manipulation_module.py +++ b/dimos/manipulation/test_agentic_manipulation_module.py @@ -25,13 +25,19 @@ from dimos.manipulation.agentic_manipulation_module import AgenticManipulationModule from dimos.manipulation.skill_errors import ManipulationSkillError -Call: TypeAlias = tuple[str, tuple[str | None, ...]] +Call: TypeAlias = tuple[str, tuple[str | float | None, ...]] GetStateMethod: TypeAlias = Callable[ [AgenticManipulationModule, str | None], SkillResult[ManipulationSkillError] ] MoveToJointsMethod: TypeAlias = Callable[ [AgenticManipulationModule, str, str | None], SkillResult[ManipulationSkillError] ] +SetMotionSpeedMethod: TypeAlias = Callable[ + [AgenticManipulationModule, float], SkillResult[ManipulationSkillError] +] +GetMotionSpeedMethod: TypeAlias = Callable[ + [AgenticManipulationModule], SkillResult[ManipulationSkillError] +] class FakeManipulationProvider: @@ -49,6 +55,14 @@ def move_to_joints( self.calls.append(("move_to_joints", (joints, robot_name))) return self.result + def set_motion_speed(self, speed_scale: float) -> bool: + self.calls.append(("set_motion_speed", (speed_scale,))) + return True + + def get_motion_speed(self) -> float: + self.calls.append(("get_motion_speed", ())) + return 0.5 + def open_gripper(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: self.calls.append(("open_gripper", (robot_name,))) return self.result @@ -120,6 +134,37 @@ def test_open_gripper_delegates_exact_arguments_and_result( assert provider.calls == [("open_gripper", (None,))] +def test_set_motion_speed_delegates_exact_arguments_and_result( + module: AgenticManipulationModule, + provider: FakeManipulationProvider, +) -> None: + set_motion_speed = cast( + "SetMotionSpeedMethod", AgenticManipulationModule.set_motion_speed.__wrapped__ + ) + + result = set_motion_speed(module, 0.5) + + assert result.success is True + assert result.message == "Motion speed scale set to 0.50x. Re-plan to apply it." + assert provider.calls == [("set_motion_speed", (0.5,))] + + +def test_get_motion_speed_delegates_exact_arguments_and_result( + module: AgenticManipulationModule, + provider: FakeManipulationProvider, +) -> None: + get_motion_speed = cast( + "GetMotionSpeedMethod", AgenticManipulationModule.get_motion_speed.__wrapped__ + ) + + result = get_motion_speed(module) + + assert result.success is True + assert result.message == "Current motion speed scale is 0.50x." + assert result.metadata["speed_scale"] == pytest.approx(0.5) + assert provider.calls == [("get_motion_speed", ())] + + def test_close_gripper_delegates_exact_arguments_and_result( module: AgenticManipulationModule, provider: FakeManipulationProvider, @@ -154,6 +199,8 @@ def test_decorated_skill_call_preserves_provider_result_semantics( [ ("get_robot_state", {"robot_name": str | None}), ("move_to_joints", {"joints": str, "robot_name": str | None}), + ("set_motion_speed", {"speed_scale": float}), + ("get_motion_speed", {}), ("open_gripper", {"robot_name": str | None}), ("close_gripper", {"robot_name": str | None}), ], @@ -180,7 +227,14 @@ def test_skill_methods_have_schema_safe_metadata( def test_skill_methods_generate_get_skills_schemas(module: AgenticManipulationModule) -> None: skills = {skill.func_name: skill for skill in module.get_skills()} - assert set(skills) == {"close_gripper", "get_robot_state", "move_to_joints", "open_gripper"} + assert set(skills) == { + "close_gripper", + "get_motion_speed", + "get_robot_state", + "move_to_joints", + "open_gripper", + "set_motion_speed", + } for method_name in skills: schema = json.loads(skills[method_name].args_schema) assert schema["type"] == "object" @@ -189,3 +243,6 @@ def test_skill_methods_generate_get_skills_schemas(module: AgenticManipulationMo assert "joints" in move_schema["properties"] assert "robot_name" in move_schema["properties"] assert move_schema["required"] == ["joints"] + speed_schema = json.loads(skills["set_motion_speed"].args_schema) + assert "speed_scale" in speed_schema["properties"] + assert speed_schema["required"] == ["speed_scale"] diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 833feb029b..9198e847f3 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -31,11 +31,20 @@ from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor -from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.manipulation.planning.spec.enums import IKStatus, PlanningStatus +from dimos.manipulation.planning.spec.config import ( + RobotModelConfig, + TrajectoryParametrizationConfig, +) +from dimos.manipulation.planning.spec.enums import ( + IKStatus, + ParametrizationStatus, + PlanningStatus, + TrajectoryDispatchStatus, +) from dimos.manipulation.planning.spec.models import ( CollisionCheckResult, GeneratedPlan, + GeneratedTrajectory, IKResult, PlanningSceneInfo, ) @@ -107,7 +116,13 @@ def __init__(self) -> None: self._kinematics = None self._coordinator_client = None self._last_plan = None - self.config = MagicMock(planning_timeout=10.0) + self._last_trajectory = None + self._motion_speed_scale = 1.0 + self.config = MagicMock( + planning_timeout=10.0, + trajectory_parametrization=TrajectoryParametrizationConfig(), + coordinator_rpc_timeout=3.0, + ) def _make_module() -> ManipulationModule: @@ -115,6 +130,96 @@ def _make_module() -> ManipulationModule: return _ManipulationModuleHarness() +def _successful_generated_trajectory() -> GeneratedTrajectory: + """Create a successful global generated trajectory for dispatch tests.""" + return GeneratedTrajectory( + joint_names=["test_arm/joint2", "test_arm/joint1"], + points=[ + TrajectoryPoint( + time_from_start=0.0, + positions=[0.2, 0.1], + velocities=[0.02, 0.01], + ), + TrajectoryPoint( + time_from_start=1.5, + positions=[0.4, 0.3], + velocities=[0.04, 0.03], + ), + ], + duration=1.5, + status=ParametrizationStatus.SUCCESS, + source_group_ids=("test_arm/manipulator",), + ) + + +class TestTrajectoryDispatchPreparation: + """Test projection from global generated trajectories to coordinator tasks.""" + + def test_prepare_trajectory_dispatch_preserves_timing_and_holds_unselected_joints( + self, robot_config: RobotModelConfig + ) -> None: + module = _make_module() + module._robots["test_arm"] = ("world_test_arm", robot_config, MagicMock()) + planning_groups = MagicMock() + planning_groups.select.return_value = MagicMock(robot_names=("test_arm",)) + world_monitor = MagicMock(planning_groups=planning_groups) + world_monitor.get_current_joint_state.return_value = JointState( + {"name": ["joint1", "joint2", "joint3"], "position": [9.0, 8.0, 7.0]} + ) + module._world_monitor = world_monitor + + dispatch = module._prepare_trajectory_dispatch(_successful_generated_trajectory()) + + assert dispatch.status == TrajectoryDispatchStatus.SUCCESS + assert dispatch.robot_names_by_task == {"traj_arm": "test_arm"} + task_trajectory = dispatch.trajectories_by_task["traj_arm"] + assert task_trajectory.joint_names == [ + "test_arm/joint1", + "test_arm/joint2", + "test_arm/joint3", + ] + assert [point.time_from_start for point in task_trajectory.points] == [0.0, 1.5] + assert task_trajectory.points[0].positions == pytest.approx([0.1, 0.2, 7.0]) + assert task_trajectory.points[0].velocities == pytest.approx([0.01, 0.02, 0.0]) + assert task_trajectory.points[1].positions == pytest.approx([0.3, 0.4, 7.0]) + assert task_trajectory.points[1].velocities == pytest.approx([0.03, 0.04, 0.0]) + world_monitor.get_current_joint_state.assert_called_once_with("world_test_arm") + planning_groups.select.assert_called_once_with(("test_arm/manipulator",)) + + def test_prepare_trajectory_dispatch_reports_missing_task( + self, robot_config: RobotModelConfig + ) -> None: + module = _make_module() + robot_without_task = robot_config.model_copy(update={"coordinator_task_name": None}) + module._robots["test_arm"] = ("world_test_arm", robot_without_task, MagicMock()) + planning_groups = MagicMock() + planning_groups.select.return_value = MagicMock(robot_names=("test_arm",)) + module._world_monitor = MagicMock(planning_groups=planning_groups) + + dispatch = module._prepare_trajectory_dispatch(_successful_generated_trajectory()) + + assert dispatch.status == TrajectoryDispatchStatus.MISSING_TASK + assert "No coordinator_task_name" in dispatch.message + + def test_prepare_trajectory_dispatch_reports_missing_current_joint( + self, robot_config: RobotModelConfig + ) -> None: + module = _make_module() + module._robots["test_arm"] = ("world_test_arm", robot_config, MagicMock()) + planning_groups = MagicMock() + planning_groups.select.return_value = MagicMock(robot_names=("test_arm",)) + world_monitor = MagicMock(planning_groups=planning_groups) + world_monitor.get_current_joint_state.return_value = JointState( + {"name": ["joint1", "joint2"], "position": [9.0, 8.0]} + ) + module._world_monitor = world_monitor + + dispatch = module._prepare_trajectory_dispatch(_successful_generated_trajectory()) + + assert dispatch.status == TrajectoryDispatchStatus.MISSING_JOINT + assert "missing joint 'test_arm/joint3'" in dispatch.message + + class TestStateMachine: """Test state transitions.""" @@ -259,6 +364,7 @@ def test_kinematics_config_is_passed_to_factory( planning_initialization.mock_planning_specs.assert_called_once_with( world=planning_initialization.mock_world, + world_backend="drake", planner_name="rrt_connect", kinematics_name=None, kinematics=kinematics, @@ -278,6 +384,7 @@ def test_legacy_kinematics_name_still_selects_backend( planning_initialization.mock_planning_specs.assert_called_once_with( world=planning_initialization.mock_world, + world_backend="drake", planner_name="rrt_connect", kinematics_name="pink", kinematics=module.config.kinematics, @@ -432,7 +539,14 @@ def test_execute_requires_task_name(self): path=[JointState(name=["arm/j1"], position=[1.0])], status=PlanningStatus.SUCCESS, ) - + module._last_trajectory = GeneratedTrajectory( + joint_names=["arm/j1"], + points=[TrajectoryPoint(time_from_start=1.5, positions=[1.0])], + duration=1.5, + status=ParametrizationStatus.SUCCESS, + source_group_ids=("arm/manipulator",), + source_plan_status=PlanningStatus.SUCCESS, + ) assert module.execute() is False def test_execute_success(self, robot_config, simple_trajectory): @@ -461,7 +575,6 @@ def test_execute_success(self, robot_config, simple_trajectory): ], status=PlanningStatus.SUCCESS, ) - mock_client = MagicMock() mock_client.task_invoke.return_value = True module._coordinator_client = mock_client @@ -476,7 +589,13 @@ def test_execute_success(self, robot_config, simple_trajectory): "test_arm/joint2", "test_arm/joint3", ] - assert trajectory.points == simple_trajectory.points + assert len(trajectory.points) > 2 + assert trajectory.points[0].positions == pytest.approx([0.0, 0.0, 0.0]) + assert trajectory.points[-1].positions == pytest.approx([0.5, 0.5, 0.5]) + point_times = [point.time_from_start for point in trajectory.points] + assert point_times == sorted(point_times) + assert point_times[0] == pytest.approx(0.0) + assert point_times[-1] > point_times[0] def test_execute_rejected(self, robot_config, simple_trajectory): """Rejected execution sets FAULT state.""" @@ -504,7 +623,6 @@ def test_execute_rejected(self, robot_config, simple_trajectory): ], status=PlanningStatus.SUCCESS, ) - mock_client = MagicMock() mock_client.task_invoke.return_value = False module._coordinator_client = mock_client @@ -906,26 +1024,53 @@ def test_preview_plan_uses_safe_default_duration(self): module._world_monitor = MagicMock() module._last_plan = GeneratedPlan( group_ids=("arm/manipulator",), - path=[JointState(name=["arm/j1"], position=[0.0])], + path=[ + JointState(name=["arm/j1"], position=[0.0]), + JointState(name=["arm/j1"], position=[1.0]), + ], status=PlanningStatus.SUCCESS, ) + module._last_trajectory = GeneratedTrajectory( + joint_names=["arm/j1"], + points=[TrajectoryPoint(time_from_start=1.5, positions=[1.0])], + duration=1.5, + status=ParametrizationStatus.SUCCESS, + source_group_ids=("arm/manipulator",), + source_plan_status=PlanningStatus.SUCCESS, + ) assert module.preview_plan() is True - module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 1.0) + preview_plan, preview_duration = module._world_monitor.animate_plan.call_args.args + assert preview_plan.group_ids == module._last_plan.group_ids + assert preview_plan.path[0].name == ["arm/j1"] + assert preview_duration == 1.5 def test_preview_plan_explicit_duration_overrides_default(self): module = _make_module() module._world_monitor = MagicMock() module._last_plan = GeneratedPlan( group_ids=("arm/manipulator",), - path=[JointState(name=["arm/j1"], position=[0.0])], + path=[ + JointState(name=["arm/j1"], position=[0.0]), + JointState(name=["arm/j1"], position=[1.0]), + ], status=PlanningStatus.SUCCESS, ) + module._last_trajectory = GeneratedTrajectory( + joint_names=["arm/j1"], + points=[TrajectoryPoint(time_from_start=1.5, positions=[1.0])], + duration=1.5, + status=ParametrizationStatus.SUCCESS, + source_group_ids=("arm/manipulator",), + source_plan_status=PlanningStatus.SUCCESS, + ) assert module.preview_plan(duration=1.5) is True - module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 1.5) + preview_plan, preview_duration = module._world_monitor.animate_plan.call_args.args + assert preview_plan.group_ids == module._last_plan.group_ids + assert preview_duration == 1.5 def test_preview_plan_respects_robot_filter(self): module = _make_module() @@ -935,13 +1080,26 @@ def test_preview_plan_respects_robot_filter(self): ) module._last_plan = GeneratedPlan( group_ids=("arm/manipulator",), - path=[JointState(name=["arm/j1"], position=[0.0])], + path=[ + JointState(name=["arm/j1"], position=[0.0]), + JointState(name=["arm/j1"], position=[1.0]), + ], status=PlanningStatus.SUCCESS, ) + module._last_trajectory = GeneratedTrajectory( + joint_names=["arm/j1"], + points=[TrajectoryPoint(time_from_start=1.5, positions=[1.0])], + duration=1.5, + status=ParametrizationStatus.SUCCESS, + source_group_ids=("arm/manipulator",), + source_plan_status=PlanningStatus.SUCCESS, + ) assert module.preview_plan(robot_name="arm") is True - module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 1.0) + preview_plan, preview_duration = module._world_monitor.animate_plan.call_args.args + assert preview_plan.group_ids == module._last_plan.group_ids + assert preview_duration == 1.5 def test_preview_plan_rejects_unaffected_robot_filter(self): module = _make_module() @@ -1030,14 +1188,16 @@ def test_execute_plan_dispatches_one_trajectory_per_affected_robot(self): assert left_call.args[0:2] == ("left_task", "execute") left_trajectory = left_call.args[2]["trajectory"] assert left_trajectory.joint_names == ["left/j1", "left/j2", "left/j3"] - assert [point.positions for point in left_trajectory.points] == [ - [1.0, 2.0, 9.0], - [4.0, 5.0, 9.0], - ] + assert left_trajectory.points[0].positions == pytest.approx([1.0, 2.0, 9.0]) + assert left_trajectory.points[-1].positions == pytest.approx([4.0, 5.0, 9.0]) assert right_call.args[0:2] == ("right_task", "execute") right_trajectory = right_call.args[2]["trajectory"] assert right_trajectory.joint_names == ["right/j1", "right/j2"] - assert [point.positions for point in right_trajectory.points] == [[3.0, 8.0], [6.0, 8.0]] + assert right_trajectory.points[0].positions == pytest.approx([3.0, 8.0]) + assert right_trajectory.points[-1].positions == pytest.approx([6.0, 8.0]) + left_times = [point.time_from_start for point in left_trajectory.points] + right_times = [point.time_from_start for point in right_trajectory.points] + assert left_times == right_times def test_execute_plan_holds_non_selected_joints_from_current_state(self): config = _make_robot_config("left", ["j1", "j2", "j3"], "task") @@ -1065,10 +1225,10 @@ def test_execute_plan_holds_non_selected_joints_from_current_state(self): trajectory = module._coordinator_client.task_invoke.call_args.args[2]["trajectory"] assert trajectory.joint_names == ["left/j1", "left/j2", "left/j3"] - assert [point.positions for point in trajectory.points] == [ - [10.0, 2.0, 30.0], - [10.0, 3.0, 30.0], - ] + assert trajectory.points[0].positions == pytest.approx([10.0, 2.0, 30.0]) + assert trajectory.points[-1].positions == pytest.approx([10.0, 3.0, 30.0]) + point_times = [point.time_from_start for point in trajectory.points] + assert point_times == sorted(point_times) def test_execute_plan_rejects_local_waypoint_names(self): config = _make_robot_config("left", ["j1", "j2"], "task") @@ -1106,7 +1266,157 @@ def test_preview_plan_with_last_plan_animates_generated_plan(self): assert module.preview_plan(robot_name="left") is True - module._world_monitor.animate_plan.assert_called_once_with(module._last_plan, 1.0) + preview_plan, preview_duration = module._world_monitor.animate_plan.call_args.args + assert preview_plan.group_ids == module._last_plan.group_ids + assert preview_plan.path[0].name == ["left/j1"] + assert preview_duration == 1.5 + + def test_explicit_plan_parametrization_does_not_poison_last_trajectory(self): + config = _make_robot_config("left", ["j1"], "task") + module = _make_module_with_monitor(config) + last_plan = GeneratedPlan( + group_ids=("left/arm",), + path=[ + JointState(name=["left/j1"], position=[0.0]), + JointState(name=["left/j1"], position=[1.0]), + ], + status=PlanningStatus.SUCCESS, + ) + explicit_plan = GeneratedPlan( + group_ids=("left/arm",), + path=[ + JointState(name=["left/j1"], position=[2.0]), + JointState(name=["left/j1"], position=[3.0]), + ], + status=PlanningStatus.SUCCESS, + ) + cached = GeneratedTrajectory( + joint_names=["left/j1"], + points=[TrajectoryPoint(time_from_start=0.0, positions=[9.0])], + duration=0.0, + status=ParametrizationStatus.SUCCESS, + source_group_ids=("left/arm",), + ) + module._last_plan = last_plan + module._last_trajectory = cached + + explicit_trajectory = module._parametrize_plan(explicit_plan) + + assert explicit_trajectory.is_success() + assert module._last_trajectory is cached + assert module._trajectory_for_plan(last_plan) is cached + + def test_motion_speed_scale_preserves_cached_trajectory_and_plan(self): + module = _make_module() + cached_plan = GeneratedPlan( + group_ids=("left/arm",), + path=[JointState(name=["left/j1"], position=[0.0])], + status=PlanningStatus.SUCCESS, + ) + module._last_plan = cached_plan + cached_trajectory = GeneratedTrajectory( + joint_names=["left/j1"], + points=[TrajectoryPoint(time_from_start=0.0, positions=[0.0])], + duration=0.0, + speed_scale=1.0, + status=ParametrizationStatus.SUCCESS, + source_group_ids=("left/arm",), + ) + module._last_trajectory = cached_trajectory + + assert module.set_motion_speed(0.5) is True + + assert module.get_motion_speed() == pytest.approx(0.5) + assert module._last_plan is cached_plan + assert module._last_trajectory is cached_trajectory + assert getattr(module.set_motion_speed, "__skill__", False) is False + assert getattr(module.get_motion_speed, "__skill__", False) is False + + def test_parametrize_plan_passes_configured_motion_speed_scale(self, mocker: MockerFixture): + config = _make_robot_config("left", ["j1"], "task") + module = _make_module_with_monitor(config) + module._motion_speed_scale = 0.25 + plan = GeneratedPlan( + group_ids=("left/arm",), + path=[ + JointState(name=["left/j1"], position=[0.0]), + JointState(name=["left/j1"], position=[1.0]), + ], + status=PlanningStatus.SUCCESS, + ) + trajectory = GeneratedTrajectory( + joint_names=["left/j1"], + points=[TrajectoryPoint(time_from_start=0.0, positions=[0.0])], + duration=0.0, + status=ParametrizationStatus.SUCCESS, + source_group_ids=("left/arm",), + ) + parametrizer = MagicMock() + parametrizer.parametrize.return_value = trajectory + mocker.patch.object( + module, + "_trajectory_parametrizer_for_config", + return_value=parametrizer, + ) + + assert module._parametrize_plan(plan) is trajectory + parametrizer.parametrize.assert_called_once_with(plan, speed_scale=0.25) + + def test_invalid_explicit_plan_parametrization_returns_invalid_without_mutating_plan(self): + config = _make_robot_config("left", ["j1"], "task") + module = _make_module_with_monitor(config) + plan = GeneratedPlan( + group_ids=("left/arm",), + path=[JointState(name=["j1"], position=[1.0])], + status=PlanningStatus.SUCCESS, + message="source ok", + ) + + trajectory = module._parametrize_plan(plan) + + assert trajectory.status == ParametrizationStatus.INVALID_PLAN + assert "global" in trajectory.message.lower() or "/" in trajectory.message + assert trajectory.source_group_ids == plan.group_ids + assert trajectory.source_plan_status == PlanningStatus.SUCCESS + assert plan.status == PlanningStatus.SUCCESS + + def test_preview_and_execute_reuse_cached_generated_trajectory(self): + config = _make_robot_config("left", ["j1"], "task") + module = _make_module_with_monitor(config) + module._world_monitor.planning_groups = _FakePlanningGroups( + [_make_global_group("left", "arm", ["j1"])] + ) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1"], position=[0.0] + ) + module._coordinator_client = MagicMock() + module._last_plan = GeneratedPlan( + group_ids=("left/arm",), + path=[JointState(name=["left/j1"], position=[0.0])], + status=PlanningStatus.SUCCESS, + ) + generated = GeneratedTrajectory( + joint_names=["left/j1"], + points=[ + TrajectoryPoint(time_from_start=0.0, positions=[5.0], velocities=[0.0]), + TrajectoryPoint(time_from_start=2.5, positions=[6.0], velocities=[0.0]), + ], + duration=2.5, + status=ParametrizationStatus.SUCCESS, + source_group_ids=("left/arm",), + source_plan_status=PlanningStatus.SUCCESS, + ) + module._last_trajectory = generated + + assert module.preview_plan() is True + preview_plan, preview_duration = module._world_monitor.animate_plan.call_args.args + assert [point.position for point in preview_plan.path] == [[5.0], [6.0]] + assert preview_duration == 2.5 + + assert module.execute() is True + dispatched = module._coordinator_client.task_invoke.call_args.args[2]["trajectory"] + assert [point.positions for point in dispatched.points] == [[5.0], [6.0]] + assert [point.time_from_start for point in dispatched.points] == [0.0, 2.5] def test_has_and_clear_planned_path_use_last_plan(self): module = _make_module() diff --git a/dimos/manipulation/test_roboplan_world.py b/dimos/manipulation/test_roboplan_world.py index 8959cd49e3..c233fbaa76 100644 --- a/dimos/manipulation/test_roboplan_world.py +++ b/dimos/manipulation/test_roboplan_world.py @@ -30,9 +30,16 @@ PlanningGroupDefinition, PlanningGroupSelection, ) -from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.manipulation.planning.spec.enums import ObstacleType, PlanningStatus -from dimos.manipulation.planning.spec.models import Obstacle +from dimos.manipulation.planning.spec.config import ( + RobotModelConfig, + TrajectoryParametrizationConfig, +) +from dimos.manipulation.planning.spec.enums import ( + ObstacleType, + ParametrizationStatus, + PlanningStatus, +) +from dimos.manipulation.planning.spec.models import GeneratedPlan, Obstacle from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -49,9 +56,62 @@ def __init__( class FakeJointPath: - def __init__(self, joint_names: list[str], positions: list[np.ndarray]) -> None: + def __init__( + self, + joint_names: list[str] | None = None, + positions: list[np.ndarray] | np.ndarray | None = None, + ) -> None: + self.joint_names = joint_names or [] + self.positions = positions if positions is not None else [] + + +class FakeJointTrajectory: + def __init__( + self, + joint_names: list[str], + positions: np.ndarray, + times: np.ndarray, + ) -> None: self.joint_names = joint_names self.positions = positions + self.times = times + self.velocities = np.zeros_like(positions) + + +class FakeSplineFittingMode: + Hermite = "Hermite" + Cubic = "Cubic" + Adaptive = "Adaptive" + LinearBlend = "LinearBlend" + + +class FakeTOPPRAOptions: + def __init__(self) -> None: + self.dt = 0.01 + self.mode = FakeSplineFittingMode.Hermite + self.velocity_scale = 1.0 + self.acceleration_scale = 1.0 + self.max_adaptive_iterations = 10 + self.max_adaptive_step_size = 0.05 + self.max_blend_deviation = 0.01 + + +class FakePathParameterizerTOPPRA: + last_scene: ClassVar[FakeScene | None] = None + last_group_name: ClassVar[str | None] = None + last_path: ClassVar[FakeJointPath | None] = None + last_options: ClassVar[FakeTOPPRAOptions | None] = None + + def __init__(self, scene: FakeScene, group_name: str = "") -> None: + FakePathParameterizerTOPPRA.last_scene = scene + FakePathParameterizerTOPPRA.last_group_name = group_name + + def generate(self, path: FakeJointPath, options: FakeTOPPRAOptions) -> FakeJointTrajectory: + FakePathParameterizerTOPPRA.last_path = path + FakePathParameterizerTOPPRA.last_options = options + positions = np.asarray(path.positions, dtype=np.float64) + times = np.arange(positions.shape[0], dtype=np.float64) * options.dt + return FakeJointTrajectory(list(path.joint_names), positions, times) class FakeJointGroupInfo: @@ -180,6 +240,7 @@ def _install_fake_roboplan(monkeypatch: pytest.MonkeyPatch) -> None: core = ModuleType("roboplan.core") core.Scene = FakeScene # type: ignore[attr-defined] core.JointConfiguration = FakeJointConfiguration # type: ignore[attr-defined] + core.JointPath = FakeJointPath # type: ignore[attr-defined] def has_collisions_along_path( scene: FakeScene, @@ -201,9 +262,15 @@ def has_collisions_along_path( rrt.RRTOptions = FakeRRTOptions # type: ignore[attr-defined] rrt.RRT = FakeRRT # type: ignore[attr-defined] + toppra = ModuleType("roboplan.toppra") + toppra.PathParameterizerTOPPRA = FakePathParameterizerTOPPRA # type: ignore[attr-defined] + toppra.TOPPRAOptions = FakeTOPPRAOptions # type: ignore[attr-defined] + toppra.SplineFittingMode = FakeSplineFittingMode # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "roboplan", roboplan_pkg) monkeypatch.setitem(sys.modules, "roboplan.core", core) monkeypatch.setitem(sys.modules, "roboplan.rrt", rrt) + monkeypatch.setitem(sys.modules, "roboplan.toppra", toppra) @pytest.fixture @@ -273,6 +340,49 @@ def test_roboplan_bindings_are_imported_at_module_load(fake_roboplan: None) -> N assert module.roboplan_rrt.RRT is FakeRRT +def test_roboplan_world_parametrizes_generated_plan_with_native_toppra( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + world.configure_trajectory_parametrization( + TrajectoryParametrizationConfig( + backend="roboplan", + roboplan_dt=0.02, + roboplan_spline_mode="cubic", + velocity_scale=0.5, + acceleration_scale=0.25, + ) + ) + plan = GeneratedPlan( + group_ids=("arm/manipulator",), + path=[ + JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 1.0]), + JointState(name=["arm/joint1", "arm/joint2"], position=[0.5, 1.5]), + ], + status=PlanningStatus.SUCCESS, + message="planned", + ) + + trajectory = world.parametrize(plan, speed_scale=0.4) + + assert trajectory.status == ParametrizationStatus.SUCCESS + assert trajectory.joint_names == ["arm/joint1", "arm/joint2"] + assert trajectory.source_group_ids == ("arm/manipulator",) + assert trajectory.source_plan_message == "planned" + assert trajectory.speed_scale == pytest.approx(0.4) + assert [point.time_from_start for point in trajectory.points] == [0.0, 0.02] + assert [point.positions for point in trajectory.points] == [[0.0, 1.0], [0.5, 1.5]] + assert FakePathParameterizerTOPPRA.last_scene is world._scene + assert FakePathParameterizerTOPPRA.last_group_name + assert FakePathParameterizerTOPPRA.last_path is not None + assert FakePathParameterizerTOPPRA.last_path.joint_names == ["joint1", "joint2"] + assert FakePathParameterizerTOPPRA.last_options is not None + assert FakePathParameterizerTOPPRA.last_options.mode == FakeSplineFittingMode.Cubic + assert FakePathParameterizerTOPPRA.last_options.velocity_scale == pytest.approx(0.2) + assert FakePathParameterizerTOPPRA.last_options.acceleration_scale == pytest.approx(0.1) + + def test_robot_registration_finalization_and_joint_limits( fake_roboplan: None, robot_config: RobotModelConfig ) -> None: diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index bd6376c61c..8547ff369e 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -84,6 +84,7 @@ | GuiDropdownHandle[str] | GuiButtonHandle | GuiCheckboxHandle + | GuiSliderHandle[float] | TransformControlsHandle ) @@ -278,6 +279,7 @@ def _build_panel_controls(self, gui: GuiApi) -> None: f"Feasibility: `{self.state.feasibility.status.value}`" ) self._handles["actions_heading"] = gui.add_markdown("### Actions") + self._build_motion_settings(gui) plan_button = gui.add_button("Plan", disabled=True, color=PRIMARY_ACTION_COLOR) plan_button.on_click(lambda _: self._submit_plan()) self._handles["plan"] = plan_button @@ -314,6 +316,44 @@ def _set_scene_grid_visible(self, visible: bool) -> None: return self.scene.set_reference_grid_visible(bool(visible)) + def _build_motion_settings(self, gui: GuiApi) -> None: + """Build controls that affect future generated trajectories.""" + speed_slider = gui.add_slider( + "Next plan speed", + min=0.05, + max=1.0, + step=0.05, + initial_value=self._motion_speed_scale_for_slider(), + ) + speed_slider.on_update(lambda event: self._set_next_plan_speed(event.target.value)) + self._handles["next_plan_speed"] = speed_slider + + def _motion_speed_scale_for_slider(self) -> float: + """Return a bounded speed value for the Viser slider.""" + try: + speed_scale = float(self.manipulation_module.get_motion_speed()) + except Exception: + logger.warning("Could not read manipulation motion speed", exc_info=True) + return 1.0 + if speed_scale <= 0.0: + return 1.0 + return min(speed_scale, 1.0) + + def _set_next_plan_speed(self, speed_scale: float) -> None: + """Update next-plan speed without invalidating the current panel plan.""" + if self._closed: + return + if self.state.action_status != ActionStatus.IDLE: + self._set_recoverable_error( + "Cannot change next-plan speed while an operation is active" + ) + return + if not self.manipulation_module.set_motion_speed(float(speed_scale)): + error = self.manipulation_module.get_error() or "Invalid next-plan speed" + self._set_recoverable_error(error) + return + self.refresh() + def _refresh_selected_robot_state(self) -> None: robot_name = self.state.selected_robot if robot_name is None: @@ -979,6 +1019,7 @@ def _update_target_summary(self) -> None: ) def _update_control_state(self) -> None: + self._set_disabled("next_plan_speed", self.state.action_status != ActionStatus.IDLE) self._set_disabled("plan", not self.state.can_plan()) self._set_disabled("preview", not self.state.can_preview()) self._set_disabled( diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index ce963ce1dc..3cfc8f8303 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -170,6 +170,7 @@ class GuiSliderHandle: max: float step: float value: float + disabled: bool = False removed: bool = False update_callback: GuiCallback | None = None @@ -447,6 +448,17 @@ def get_state(self) -> str: def get_error(self) -> str: return str(getattr(self, "_error_message", "")) + def set_motion_speed(self, speed_scale: float) -> bool: + self._motion_speed = float(speed_scale) + self._motion_speed_updates = [ + *getattr(self, "_motion_speed_updates", []), + float(speed_scale), + ] + return True + + def get_motion_speed(self) -> float: + return float(getattr(self, "_motion_speed", 1.0)) + def get_current_joint_state(self, robot_name: str) -> JointState | None: robot_id = self.robot_id_for_name(robot_name) world_monitor = getattr(self, "_world_monitor", None) @@ -561,6 +573,7 @@ def make_module_with_robot() -> tuple[SimpleNamespace, FakeManipulationModule]: _state=NamedState(name="IDLE"), _error_message="", _world_monitor=world_monitor, + _motion_speed=1.0, ) return world_monitor, module @@ -611,6 +624,7 @@ def test_gui_builds_controls_in_manipulation_panel_folder( assert server.folders[0].kwargs == {"expand_by_default": True} assert "status" in gui._handles assert "robot" not in gui._handles + assert "next_plan_speed" in gui._handles assert "planning_groups_heading" in gui._handles assert "target_heading" in gui._handles assert "target_summary" in gui._handles @@ -624,6 +638,8 @@ def test_gui_builds_controls_in_manipulation_panel_folder( handle_order = list(gui._handles) assert handle_order.index(f"group:{DEFAULT_GROUP_ID}") < handle_order.index("plan") assert handle_order.index("target_summary") < handle_order.index("plan") + assert handle_order.index("actions_heading") < handle_order.index("next_plan_speed") + assert handle_order.index("next_plan_speed") < handle_order.index("plan") assert handle_order.index("plan") < handle_order.index("plan_controls_heading") assert handle_order.index("plan_controls_heading") < handle_order.index("preview") assert handle_order.index("preview") < handle_order.index("execute") @@ -637,6 +653,12 @@ def test_gui_builds_controls_in_manipulation_panel_folder( assert "Ghosts:" not in gui._handles["target_summary"].value assert isinstance(gui._handles["plan_controls_heading"], GuiMarkdownHandle) assert "Plan controls" in gui._handles["plan_controls_heading"].value + speed_slider = gui._handles["next_plan_speed"] + assert isinstance(speed_slider, GuiSliderHandle) + assert speed_slider.label == "Next plan speed" + assert speed_slider.min > 0.0 + assert speed_slider.max == pytest.approx(1.0) + assert speed_slider.value == pytest.approx(1.0) plan_button = gui._handles["plan"] assert isinstance(plan_button, GuiButtonHandle) assert plan_button.color == PRIMARY_ACTION_COLOR @@ -651,6 +673,42 @@ def test_gui_builds_controls_in_manipulation_panel_folder( assert gui._operation_worker._timeout_seconds is None +def test_next_plan_speed_slider_updates_module_without_staling_current_plan( + make_panel: Callable[..., ViserPanelGui], +) -> None: + server = FakeGuiServer() + module_context = make_module_with_robot() + module = module_context[1] + module._last_trajectory = SimpleNamespace(speed_scale=1.0) + gui = make_panel(server, module_context, ViserVisualizationConfig()) + gui.state.plan_state = PanelPlanState(status=PlanStatus.FRESH) + speed_slider = gui._handles["next_plan_speed"] + assert isinstance(speed_slider, GuiSliderHandle) + assert speed_slider.update_callback is not None + + speed_slider.value = 0.5 + speed_slider.update_callback(SimpleNamespace(target=SimpleNamespace(value=0.5))) + + assert module.get_motion_speed() == pytest.approx(0.5) + assert module._motion_speed_updates == [0.5] + assert gui.state.plan_state.status == PlanStatus.FRESH + assert "motion_speed_summary" not in gui._handles + + +def test_next_plan_speed_slider_is_disabled_during_panel_operation( + make_panel: Callable[..., ViserPanelGui], +) -> None: + server = FakeGuiServer() + gui = make_panel(server, make_module_with_robot(), ViserVisualizationConfig()) + speed_slider = gui._handles["next_plan_speed"] + assert isinstance(speed_slider, GuiSliderHandle) + + gui.state.action_status = ActionStatus.RUNNING + gui.refresh() + + assert speed_slider.disabled is True + + def test_gui_scene_grid_checkbox_toggles_reference_grid( make_panel: Callable[..., ViserPanelGui], ) -> None: @@ -1459,7 +1517,7 @@ def test_gui_rebuilding_joint_sliders_removes_stale_viser_handles( module_context = (world_monitor, module) server = FakeGuiServer() gui = make_panel(server, module_context) - stale_sliders = list(server.sliders) + stale_sliders = [slider for slider in server.sliders if slider.label != "Next plan speed"] assert [slider.value for slider in stale_sliders] == [0.0, 0.0] current.position = [-0.738, -0.2826151825863572] diff --git a/openspec/changes/add-manipulation-trajectory-parametrization-spec/design.md b/openspec/changes/add-manipulation-trajectory-parametrization-spec/design.md deleted file mode 100644 index 54b1e4b8fd..0000000000 --- a/openspec/changes/add-manipulation-trajectory-parametrization-spec/design.md +++ /dev/null @@ -1,125 +0,0 @@ -## Context - -The current manipulation pipeline separates kinematics and geometric planning through the existing multi-spec architecture, but trajectory timing is still bolted on inside execution. `GeneratedPlan` stores the selected planning groups and geometric joint path. `ManipulationModule.execute_plan()` then projects that path into robot-local paths, calls the current joint trajectory generator, wraps each result in a low-level `JointTrajectory`, and invokes coordinator tasks. - -This makes time parametrization hard to reason about. It is not a named capability, it has no explicit status, it is not available to preview or benchmarking as a stable artifact, and it can silently retime coordinated multi-robot paths independently per robot. The current generator also uses a simple per-segment trapezoidal profile that likely stops at intermediate planner waypoints and lacks explicit backend policy such as TOPPRA gridpoint selection. - -## Goals / Non-Goals - -**Goals:** - -- Introduce trajectory parametrization as a first-class manipulation-planning spec role. -- Preserve `GeneratedPlan` as a geometric planning artifact. -- Add `GeneratedTrajectory` as a global time-parametrized artifact produced from a `GeneratedPlan`. -- Add `TrajectoryDispatch` as the separate execution-preparation boundary from generated trajectory to control-task messages. -- Preserve one shared time domain across all selected joints in composite and multi-robot motions. -- Ensure preview, validation, benchmarking, and execution dispatch all consume the same `GeneratedTrajectory`. -- Keep a required `simple_trapezoid` backend that wraps current behavior and a TOPPRA backend available through an optional install extra. -- Expose backend policy controls for velocity, acceleration, minimum segment duration, and TOPPRA gridpoints/discretization. -- Distinguish planning, parametrization, dispatch, and execution failure semantics. - -**Non-Goals:** - -- Making TOPPRA the default backend in this change. -- Changing geometric planner algorithms such as RRT or IK. -- Reworking `ControlCoordinator` task internals. -- Adding a jerk-limited or S-curve backend unless it falls out naturally from a selected backend. -- Overhauling the benchmark harness beyond consuming the new generated trajectory artifact where relevant. - -## Decisions - -### TrajectoryParametrizerSpec as a new multi-spec role - -Add a `TrajectoryParametrizerSpec` Protocol that converts a successful geometric `GeneratedPlan` plus parametrization policy into a `GeneratedTrajectory`. - -Rationale: kinematics, planning, and trajectory parametrization are different robotics capabilities. Treating parametrization as a spec role keeps `ManipulationModule` as orchestration and allows simple and serious backends to share one contract. - -Alternative considered: keep time generation inside `ManipulationModule.execute_plan()`. This preserves the current implementation shape but leaves timing invisible to preview, benchmarking, validation, and failure handling. - -### GeneratedTrajectory is global and canonical - -`GeneratedTrajectory` should represent the canonical global timed joint trajectory for the selected planning groups. It should not own coordinator-task-specific projections as canonical data. - -Rationale: a generated trajectory is still a manipulation-planning artifact. Keeping it global preserves the semantics of the source `GeneratedPlan`, especially for composite and multi-robot paths with a shared time domain. It also avoids coupling parametrization backends to current coordinator task wiring. - -Alternative considered: store robot-local or task-local projections directly inside `GeneratedTrajectory`. This is convenient for execution but makes the artifact dependent on dispatch topology and invites independent per-task timing. - -### TrajectoryDispatch as a separate execution-preparation boundary - -Add `TrajectoryDispatch` as the artifact that maps a `GeneratedTrajectory` into the per-task or per-robot `JointTrajectory` messages required by `ControlCoordinator` tasks. - -Rationale: dispatch is not trajectory parametrization. Dispatch knows about current task names, joint ownership, and coordinator invocation details. Separating it lets `TrajectoryParametrizerSpec` stay independent of control-task wiring while keeping execution preparation observable and testable. - -Alternative considered: let each parametrizer emit coordinator task messages. This would make every backend depend on control task layout and would duplicate dispatch rules. - -### Shared trajectory time domain for composite motion - -A `GeneratedTrajectory` produced from a composite or multi-robot `GeneratedPlan` must preserve one shared time domain across all selected joints and robot-local projections derived during dispatch. - -Rationale: coupled planning can be invalidated if each robot is retimed independently. A shared time basis allows preview, validation, benchmarking, and execution to refer to the same coordinated motion. - -Alternative considered: independently retime each robot-local path and start all tasks together. This is simpler but breaks the semantics of coordinated composite motion and can produce relative timing changes the planner did not validate. - -### Preview, validation, benchmarking, and execution share the same artifact - -Preview, validation, benchmarking, and execution dispatch should all consume the same `GeneratedTrajectory`, not independently assign duration or timing to the geometric `GeneratedPlan`. - -Rationale: the motion the user previews, the benchmark measures, and the robot executes should be the same time-parametrized artifact. This removes the current mismatch where preview can use a fixed display duration while execution uses generated timing. - -Alternative considered: keep preview as display-only interpolation over the geometric path. This is acceptable for rough visualization but not for validating timing-sensitive behavior. - -### simple_trapezoid default and TOPPRA opt-in - -Keep `simple_trapezoid` as the initial default backend and support TOPPRA as an explicit opt-in backend. - -Rationale: this separates architecture risk from backend integration risk. The new spec/artifact boundary can land with current behavior preserved, while TOPPRA becomes available for serious parametrization and future validation before flipping defaults. - -Alternative considered: make TOPPRA the default immediately. This may be the right future direction, but it should follow validation and benchmark evidence. - -### TOPPRA install and test policy - -Add a `manipulation-toppra` optional extra containing the PyPI `toppra` package, include that extra in `all`, and require the repository test environment to run TOPPRA tests unconditionally. - -Rationale: runtime users should not need TOPPRA unless they configure it, but DimOS developers and CI should continuously validate the supported TOPPRA backend. Optional runtime dependency should not mean skipped repository coverage. - -Alternative considered: skip TOPPRA tests when import is unavailable. This hides regressions in a supported backend and contradicts the decision to include TOPPRA in the repo test surface. - -### Missing configured TOPPRA dependency fails initialization - -If configuration selects `backend="toppra"` and the dependency is unavailable, planning/parametrization initialization should fail clearly with guidance to install `dimos[manipulation-toppra]`. - -Rationale: an explicitly configured missing backend is an environment/configuration error, not a motion-planning outcome. Failing early avoids discovering the problem only when a motion is attempted. - -Alternative considered: return `GeneratedTrajectory.status = BACKEND_UNAVAILABLE` during parametrization. This is useful only for dynamic backend unavailability, not predictable missing imports from explicit configuration. - -### Separate status layers - -Keep planning, parametrization, dispatch, and execution statuses separate. A parametrization failure must not overwrite `GeneratedPlan.status`. - -Rationale: users and tests need to distinguish “planner failed to find a path,” “planner found a path but constraints made timing infeasible,” and “trajectory succeeded but dispatch or execution failed.” - -Alternative considered: use one combined status on the latest artifact. This loses diagnostic information and makes recovery decisions ambiguous. - -## Risks / Trade-offs - -- [Risk] TOPPRA's Python package exists on PyPI but upstream documentation signals possible future migration away from Python support. → Mitigation: keep the backend behind a spec boundary and optional extra so another implementation can replace it without changing orchestration. -- [Risk] Adding `GeneratedTrajectory` and `TrajectoryDispatch` increases artifact count. → Mitigation: the artifacts correspond to real pipeline boundaries: geometric planning, time parametrization, and execution preparation. -- [Risk] The `simple_trapezoid` backend may preserve current stop-at-waypoint behavior. → Mitigation: treat it as a compatibility fallback, not the final quality target. -- [Risk] Benchmark comparisons can be misleading if TOPPRA gridpoint/discretization policy varies. → Mitigation: make gridpoint/discretization policy explicit in config and tests. -- [Risk] Dispatch logic can drift from parametrization assumptions. → Mitigation: test that dispatch preserves the generated trajectory's global timing and joint ordering. - -## Migration Plan - -1. Add the new config, model, status, and Protocol definitions without changing planner algorithms. -2. Wrap current trapezoid timing behavior behind `simple_trapezoid` as the default parametrizer. -3. Change manipulation orchestration so planning produces `GeneratedPlan`, parametrization produces `GeneratedTrajectory`, and dispatch produces task-specific `JointTrajectory` messages. -4. Update preview paths to consume `GeneratedTrajectory` timing rather than an ad hoc duration. -5. Add TOPPRA backend support and dependency extra, then include it in `all` and the repo test environment. -6. Add tests for artifact contracts, backend status behavior, shared time domain, TOPPRA behavior, and dispatch preservation. -7. Rollback is restoring direct execution-time generation in `ManipulationModule` and removing the new spec/models/backends; geometric planner APIs can remain unchanged. - -## Open Questions - -- What validation or benchmark threshold should justify making TOPPRA the default backend later? -- Should future parametrizers support duration targeting or only constraint-satisfying minimum-time behavior? -- Which backend, if any, should introduce jerk-limited or S-curve profiles? diff --git a/openspec/changes/add-manipulation-trajectory-parametrization-spec/proposal.md b/openspec/changes/add-manipulation-trajectory-parametrization-spec/proposal.md deleted file mode 100644 index 1d96ca00c2..0000000000 --- a/openspec/changes/add-manipulation-trajectory-parametrization-spec/proposal.md +++ /dev/null @@ -1,35 +0,0 @@ -## Why - -Manipulation planning currently produces a geometric `GeneratedPlan`, then `ManipulationModule.execute_plan()` assigns timing as an execution-time implementation detail using the current joint trajectory generator. Preview, execution, and future benchmarking can therefore use different temporal assumptions, and composite or multi-robot plans can be split into independently timed per-robot trajectories even when the geometric plan was coordinated. - -DimOS needs trajectory parametrization as a first-class manipulation-planning capability: a planner produces a geometric path, a parametrizer assigns time under motion constraints, and orchestration dispatches the resulting trajectory to control tasks without conflating planning, parametrization, and execution status. - -## What Changes - -- Add a `TrajectoryParametrizerSpec` role to the manipulation multi-spec architecture. -- Add `GeneratedTrajectory` as the canonical global time-parametrized manipulation artifact produced from a `GeneratedPlan`. -- Add `TrajectoryDispatch` as the execution-preparation artifact that derives task-specific `JointTrajectory` messages from a `GeneratedTrajectory`. -- Preserve a shared trajectory time domain for composite and multi-robot motions. -- Make preview, validation, benchmarking, and execution dispatch consume the same `GeneratedTrajectory` artifact. -- Provide a default `simple_trapezoid` backend that wraps the current timing behavior behind the new spec boundary. -- Add optional TOPPRA support through a `manipulation-toppra` extra and include that extra in `all` and the repository test environment. -- Keep TOPPRA explicit opt-in initially; do not make it the default backend until validation and benchmarks justify the switch. - -## Capabilities - -### New Capabilities -- `manipulation-trajectory-parametrization`: First-class trajectory parametrization, generated trajectory artifacts, dispatch preparation, and backend policy for manipulation planning. - -### Modified Capabilities - -## Impact - -- Affected code areas: - - `dimos/manipulation/planning/spec/` for the new spec role, models, config, and status types. - - `dimos/manipulation/planning/trajectory_generator/` or successor backend package for the `simple_trapezoid` and TOPPRA parametrizers. - - `dimos/manipulation/manipulation_module.py` for orchestration, preview, parametrization, and dispatch flow. - - `dimos/msgs/trajectory_msgs/` only as the low-level dispatch message boundary, not as the canonical manipulation artifact. - - `pyproject.toml` for the `manipulation-toppra` optional extra and inclusion in `all`. - - manipulation unit/integration tests for artifact contracts, backend behavior, preview/execution sharing, and TOPPRA execution in the repo test environment. -- Existing geometric planning APIs should remain conceptually geometric. Timing belongs to the parametrization step. -- `ControlCoordinator` task internals are out of scope; dispatch prepares existing task messages. diff --git a/openspec/changes/add-manipulation-trajectory-parametrization-spec/tasks.md b/openspec/changes/add-manipulation-trajectory-parametrization-spec/tasks.md deleted file mode 100644 index b73b038913..0000000000 --- a/openspec/changes/add-manipulation-trajectory-parametrization-spec/tasks.md +++ /dev/null @@ -1,40 +0,0 @@ -## 1. Spec, Config, and Models - -- [ ] 1.1 Add trajectory parametrization config with backend selection, velocity/acceleration scales, optional velocity/acceleration limits, optional minimum segment duration, and explicit TOPPRA gridpoint/discretization policy. -- [ ] 1.2 Add `TrajectoryParametrizerSpec` as a DimOS Spec Protocol that converts a successful `GeneratedPlan` into a `GeneratedTrajectory`. -- [ ] 1.3 Add `GeneratedTrajectory` as the canonical global timed manipulation artifact with joint names, timed points, shared duration/time domain, status, message, and source planning metadata. -- [ ] 1.4 Add `TrajectoryDispatch` as the execution-preparation artifact that contains task-specific `JointTrajectory` messages and dispatch status/message without changing `GeneratedTrajectory` timing. -- [ ] 1.5 Add explicit parametrization and dispatch status values that do not overwrite `GeneratedPlan.status`. - -## 2. Parametrization Backends - -- [ ] 2.1 Wrap the current trapezoidal timing logic behind a `simple_trapezoid` trajectory parametrizer backend. -- [ ] 2.2 Preserve current default behavior by selecting `simple_trapezoid` unless another backend is configured. -- [ ] 2.3 Add a TOPPRA backend using the PyPI `toppra` Python API for joint velocity and acceleration constrained path parametrization. -- [ ] 2.4 Fail planning/parametrization initialization clearly when `backend="toppra"` is configured but TOPPRA cannot be imported, including guidance to install `dimos[manipulation-toppra]`. -- [ ] 2.5 Ensure TOPPRA policy exposes gridpoint/discretization behavior explicitly enough for reproducible tests and benchmark comparisons. - -## 3. Orchestration and Dispatch - -- [ ] 3.1 Update manipulation planning flow so geometric planning stores `GeneratedPlan` and a separate parametrization step stores `GeneratedTrajectory`. -- [ ] 3.2 Update preview, validation, and benchmark-facing paths to consume `GeneratedTrajectory` instead of independently timing `GeneratedPlan`. -- [ ] 3.3 Add dispatch orchestration in `ManipulationModule` that maps global `GeneratedTrajectory` into coordinator task-specific `JointTrajectory` messages. -- [ ] 3.4 Ensure `TrajectoryParametrizerSpec` has no dependency on `ControlCoordinator` task names or task wiring. -- [ ] 3.5 Preserve one shared trajectory time domain across composite and multi-robot generated trajectories and their dispatch projections. - -## 4. Packaging - -- [ ] 4.1 Add `manipulation-toppra = ["toppra>=0.6.8"]` or an equivalent current TOPPRA dependency constraint to optional extras. -- [ ] 4.2 Include `manipulation-toppra` in the `all` extra. -- [ ] 4.3 Ensure the repository test environment installs TOPPRA support so TOPPRA tests run unconditionally. - -## 5. Tests and Validation - -- [ ] 5.1 Add contract tests showing `GeneratedPlan` remains geometric and `GeneratedTrajectory` carries the time-parametrized global path. -- [ ] 5.2 Add tests that parametrization failure leaves `GeneratedPlan.status` unchanged and reports failure on `GeneratedTrajectory`. -- [ ] 5.3 Add `simple_trapezoid` tests for joint ordering, monotonic `time_from_start`, final waypoint preservation, and shared time domain behavior. -- [ ] 5.4 Add TOPPRA tests that run unconditionally in the repo test environment and cover velocity/acceleration constraints plus explicit gridpoint/discretization policy. -- [ ] 5.5 Add dispatch tests proving task-specific `JointTrajectory` messages preserve global generated trajectory timing and joint ordering. -- [ ] 5.6 Add orchestration tests proving preview and execution dispatch consume the same `GeneratedTrajectory` artifact. -- [ ] 5.7 Run focused manipulation trajectory parametrization tests. -- [ ] 5.8 Run OpenSpec validation for this change and fix proposal, spec, design, or task formatting issues. diff --git a/openspec/specs/manipulation-next-plan-speed-control/spec.md b/openspec/specs/manipulation-next-plan-speed-control/spec.md new file mode 100644 index 0000000000..8db4938aca --- /dev/null +++ b/openspec/specs/manipulation-next-plan-speed-control/spec.md @@ -0,0 +1,61 @@ +## Purpose + +Define next-plan manipulation speed control semantics and the minimal Viser slider used to update future trajectory generation without mutating existing generated trajectories. + +## Requirements + +### Requirement: Next-plan speed setting +The system SHALL treat manipulation motion speed as a next-plan trajectory generation setting. + +#### Scenario: Setting speed before planning +- **WHEN** an operator sets a valid speed scale before generating a manipulation plan +- **THEN** the next generated trajectory MUST use that speed scale during parametrization + +#### Scenario: Setting speed after planning +- **WHEN** an operator changes the speed scale after a `GeneratedTrajectory` has already been produced +- **THEN** the existing generated trajectory MUST remain unchanged and executable at the speed scale captured when it was generated + +#### Scenario: Invalid speed is rejected +- **WHEN** an operator sets a speed scale that is not greater than zero or is greater than one +- **THEN** the system MUST reject the update and keep the previous next-plan speed setting + +### Requirement: Generated trajectory speed metadata +The system SHALL record the speed scale used to create each `GeneratedTrajectory`. + +#### Scenario: Trajectory records generation speed +- **WHEN** trajectory parametrization produces a `GeneratedTrajectory` +- **THEN** the generated trajectory MUST include the speed scale used for that parametrization invocation + +#### Scenario: Current and next speeds can differ +- **WHEN** the next-plan speed setting changes after a plan is generated +- **THEN** consumers MUST be able to inspect the frozen generated trajectory speed separately from the next-plan speed setting when they need that distinction + +### Requirement: Option-one backend speed scaling +The system SHALL apply next-plan speed by multiplying configured velocity and acceleration scales by the same speed scale. + +#### Scenario: Simple trapezoid backend scales limits +- **WHEN** `simple_trapezoid` parametrizes a plan with `speed_scale = S` +- **THEN** it MUST use `configured_velocity_scale * S` and `configured_acceleration_scale * S` for effective velocity and acceleration limits + +#### Scenario: RoboPlan backend scales options +- **WHEN** the RoboPlan backend parametrizes a plan with `speed_scale = S` +- **THEN** it MUST set RoboPlan velocity and acceleration options to configured scales multiplied by `S` + +#### Scenario: Reduced speed slows in-house trajectory +- **WHEN** the same geometric plan is parametrized by `simple_trapezoid` with a reduced speed scale +- **THEN** the generated trajectory duration MUST be no shorter than the duration produced with the larger speed scale while preserving the final waypoint + +### Requirement: Viser next-plan speed control +The Viser manipulation panel SHALL expose a minimal next-plan speed slider that updates future trajectory generation without mutating the current plan. + +#### Scenario: Slider updates next-plan speed +- **WHEN** the operator changes the Viser next-plan speed slider +- **THEN** the panel MUST call the manipulation module speed setter with the selected value + +#### Scenario: Existing plan remains fresh +- **WHEN** the next-plan speed slider changes while a fresh plan exists +- **THEN** the panel MUST NOT mark the current plan stale solely because the speed setting changed + +#### Scenario: Slider remains visually minimal +- **WHEN** the panel displays the next-plan speed control +- **THEN** it MUST show only the `Next plan speed` slider near the Plan action without additional motion-settings heading, helper copy, or current-plan speed text diff --git a/openspec/changes/add-manipulation-trajectory-parametrization-spec/specs/manipulation-trajectory-parametrization/spec.md b/openspec/specs/manipulation-trajectory-parametrization/spec.md similarity index 57% rename from openspec/changes/add-manipulation-trajectory-parametrization-spec/specs/manipulation-trajectory-parametrization/spec.md rename to openspec/specs/manipulation-trajectory-parametrization/spec.md index f3b0dd3b77..e20dc9d338 100644 --- a/openspec/changes/add-manipulation-trajectory-parametrization-spec/specs/manipulation-trajectory-parametrization/spec.md +++ b/openspec/specs/manipulation-trajectory-parametrization/spec.md @@ -1,4 +1,8 @@ -## ADDED Requirements +## Purpose + +Define first-class manipulation trajectory parametrization as the boundary between geometric planning and control-task dispatch, including generated trajectory artifacts, backend policy, speed scaling, and status semantics. + +## Requirements ### Requirement: First-class trajectory parametrization spec The system SHALL provide a `TrajectoryParametrizerSpec` manipulation-planning role that converts a successful geometric `GeneratedPlan` into a time-parametrized `GeneratedTrajectory`. @@ -11,6 +15,10 @@ The system SHALL provide a `TrajectoryParametrizerSpec` manipulation-planning ro - **WHEN** a trajectory parametrization backend is implemented - **THEN** it MUST NOT depend on `ControlCoordinator` task names, task instances, or task-specific dispatch wiring +#### Scenario: Parametrizer accepts runtime speed policy without storing it +- **WHEN** a caller invokes `TrajectoryParametrizerSpec.parametrize` +- **THEN** the caller MAY provide a `speed_scale` argument for that invocation and the backend MUST NOT require persistent mutable speed state on the parametrizer spec + ### Requirement: Generated trajectory artifact The system SHALL define `GeneratedTrajectory` as the canonical global time-parametrized manipulation artifact produced from a `GeneratedPlan`. @@ -45,7 +53,7 @@ The system SHALL define a `TrajectoryDispatch` execution-preparation artifact th - **THEN** execution-specific projections MUST be produced by a separate dispatch/preparation step rather than stored as canonical generated trajectory data ### Requirement: Shared generated trajectory for preview, validation, benchmarking, and execution -Preview, validation, benchmarking, and execution dispatch SHALL consume the same `GeneratedTrajectory` artifact for a planned manipulation motion. +Preview, validation, benchmarking, and execution dispatch SHALL consume the same `GeneratedTrajectory` artifact for a planned manipulation motion where those paths exist. #### Scenario: Preview uses generated trajectory timing - **WHEN** a planned motion is previewed @@ -56,34 +64,57 @@ Preview, validation, benchmarking, and execution dispatch SHALL consume the same - **THEN** execution dispatch MUST consume the same `GeneratedTrajectory` artifact used by preview, validation, or benchmarking for that motion ### Requirement: Parametrization backend policy -The system SHALL support backend-configurable trajectory parametrization policy with a default `simple_trapezoid` backend and an explicit opt-in TOPPRA backend. +The system SHALL support backend-configurable trajectory parametrization policy with a default `simple_trapezoid` backend and an explicit opt-in RoboPlan backend. #### Scenario: Default backend is available - **WHEN** no trajectory parametrization backend is explicitly configured - **THEN** the system MUST use a `simple_trapezoid` backend that preserves current baseline timing behavior behind the new spec boundary -#### Scenario: TOPPRA backend is configured and installed -- **WHEN** `backend="toppra"` is configured and TOPPRA is available -- **THEN** the system MUST use TOPPRA for joint velocity and acceleration constrained trajectory parametrization +#### Scenario: RoboPlan backend is configured with RoboPlan world +- **WHEN** `backend="roboplan"` is configured with `world_backend="roboplan"` +- **THEN** the system MUST use RoboPlan-owned scene, group, native-joint mapping, and bundled TOPP-RA wrapper for trajectory parametrization + +#### Scenario: RoboPlan backend is configured with a non-RoboPlan world +- **WHEN** `backend="roboplan"` is configured without `world_backend="roboplan"` +- **THEN** initialization MUST fail clearly with guidance to select the RoboPlan world backend + +#### Scenario: RoboPlan TOPP-RA wrapper is unavailable +- **WHEN** `backend="roboplan"` is configured and RoboPlan's trajectory parametrization wrapper cannot be imported +- **THEN** parametrization MUST report `BACKEND_UNAVAILABLE` with guidance to install RoboPlan-backed TOPP-RA support + +#### Scenario: RoboPlan backend policy is reproducible +- **WHEN** RoboPlan parametrization is configured for tests or benchmarks +- **THEN** the RoboPlan wrapper options (`dt`, spline mode, adaptive controls, and velocity/acceleration scales) MUST be explicit enough for reproducible comparisons + +### Requirement: Runtime motion speed tuning +The system SHALL expose a runtime motion speed scale API that affects future plan-generated trajectory parametrizations without changing geometric plans, static backend config, or already generated trajectories. + +#### Scenario: Runtime speed scale is updated +- **WHEN** an operator or agent sets a valid motion speed scale in `(0, 1]` +- **THEN** the module MUST store the new runtime scale for the next generated trajectory without clearing an existing cached `GeneratedTrajectory` or mutating the cached `GeneratedPlan` + +#### Scenario: Runtime speed scale is applied during future parametrization +- **WHEN** a new generated plan is parametrized after the runtime speed scale is changed +- **THEN** the effective velocity and acceleration scales passed to the backend MUST multiply configured scales by the runtime speed scale -#### Scenario: TOPPRA backend is configured but missing -- **WHEN** `backend="toppra"` is configured and TOPPRA cannot be imported during planning or parametrization initialization -- **THEN** initialization MUST fail clearly with guidance to install TOPPRA support through `dimos[manipulation-toppra]` +#### Scenario: Existing generated trajectory remains frozen +- **WHEN** the runtime speed scale changes after a `GeneratedTrajectory` has already been produced +- **THEN** the existing generated trajectory MUST remain available for preview and execution with the speed scale captured when it was generated -#### Scenario: TOPPRA policy is reproducible -- **WHEN** TOPPRA parametrization is configured for tests or benchmarks -- **THEN** gridpoint or discretization policy MUST be explicit enough for reproducible comparisons +#### Scenario: Invalid runtime speed scale is rejected +- **WHEN** an operator or agent sets a speed scale that is not greater than zero or is greater than one +- **THEN** the module MUST reject the update and avoid changing the current runtime speed scale -### Requirement: TOPPRA packaging and repository tests -The system SHALL expose TOPPRA support through a `manipulation-toppra` optional extra, include that extra in `all`, and run TOPPRA parametrization tests unconditionally in the repository test environment. +### Requirement: RoboPlan TOPP-RA packaging and repository tests +The system SHALL expose RoboPlan-backed TOPP-RA support through the existing `manipulation-toppra` optional extra, include that extra in `all`, and run RoboPlan trajectory parametrization tests unconditionally in the repository test environment. -#### Scenario: Runtime user omits TOPPRA extra -- **WHEN** a runtime installation omits `manipulation-toppra` and does not configure `backend="toppra"` +#### Scenario: Runtime user omits RoboPlan backend +- **WHEN** a runtime installation does not configure `backend="roboplan"` - **THEN** baseline manipulation trajectory parametrization MUST remain available through `simple_trapezoid` #### Scenario: Repository tests run - **WHEN** the repository test environment runs trajectory parametrization tests -- **THEN** TOPPRA tests MUST run without import-based skipping +- **THEN** RoboPlan trajectory parametrization tests MUST run without import-based skipping ### Requirement: Separate planning, parametrization, dispatch, and execution statuses The system SHALL distinguish geometric planning status, trajectory parametrization status, dispatch preparation status, and runtime execution status. diff --git a/pyproject.toml b/pyproject.toml index d8cb93fd74..ff76dcb83d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -242,7 +242,7 @@ manipulation = [ "drake>=1.40.0; sys_platform != 'darwin' and platform_machine != 'aarch64'", "pin-pink>=4.2.0", "qpsolvers[proxqp]>=4.12.0", - "roboplan[manipulation]>=0.0.100", + "roboplan>=0.0.100", # Hardware SDKs "can-motor-control>=0.0.2; sys_platform == 'linux' and platform_machine == 'x86_64'", @@ -265,6 +265,11 @@ manipulation = [ ] +manipulation-toppra = [ + # RoboPlan bundles Python 3.12-compatible TOPP-RA bindings; direct PyPI toppra is deferred. + "roboplan>=0.0.100", +] + grasp = [ # Experimental learned grasp proposal backend. VGN's catkin-style package # metadata omits most Python runtime dependencies, so list the import-time @@ -328,7 +333,7 @@ apriltag = [ ] all = [ - "dimos[agents,apriltag,base,cpu,cuda,drone,grasp,manipulation,misc,perception,sim,unitree,visualization,web]", + "dimos[agents,apriltag,base,cpu,cuda,drone,grasp,manipulation,manipulation-toppra,misc,perception,sim,unitree,visualization,web]", ] [dependency-groups] @@ -386,7 +391,7 @@ tests = [ # Deps the lint job swaps for stubs (`stubs//` or `types-*`): # the real packages are needed at test time, stubs cover mypy. - "dimos[apriltag,mapping,drone,cpu]", + "dimos[apriltag,mapping,drone,cpu,manipulation-toppra]", "mujoco>=3.3.4", "pygame>=2.6.1", "python-can>=4", @@ -611,8 +616,10 @@ ignore = [ "dimos/dashboard/dimos.rbl", "dimos/web/dimos_interface/themes.json", "dimos/manipulation/manipulation_module.py", + "dimos/manipulation/test_manipulation_unit.py", "dimos/manipulation/planning/world/drake_world.py", "dimos/manipulation/planning/world/roboplan_world.py", + "dimos/manipulation/visualization/viser/gui.py", "dimos/manipulation/visualization/viser/test_viser_visualization.py", "dimos/navigation/cmu_nav/modules/*/main.cpp", "dimos/navigation/cmu_nav/common/*.hpp", diff --git a/uv.lock b/uv.lock index ba5d85e79d..d2bf47dbd8 100644 --- a/uv.lock +++ b/uv.lock @@ -2182,6 +2182,9 @@ manipulation = [ { name = "xacro" }, { name = "xarm-python-sdk" }, ] +manipulation-toppra = [ + { name = "roboplan" }, +] mapping = [ { name = "gtsam-extended" }, ] @@ -2358,7 +2361,7 @@ project-deps = [ tests = [ { name = "chromadb" }, { name = "coverage" }, - { name = "dimos", extra = ["apriltag", "cpu", "drone", "mapping", "visualization", "web"] }, + { name = "dimos", extra = ["apriltag", "cpu", "drone", "manipulation-toppra", "mapping", "visualization", "web"] }, { name = "einops" }, { name = "gdown" }, { name = "googlemaps" }, @@ -2402,7 +2405,7 @@ tests = [ tests-self-hosted = [ { name = "chromadb" }, { name = "coverage" }, - { name = "dimos", extra = ["agents", "apriltag", "cpu", "drone", "manipulation", "mapping", "misc", "perception", "sim", "unitree", "visualization", "web"] }, + { name = "dimos", extra = ["agents", "apriltag", "cpu", "drone", "manipulation", "manipulation-toppra", "mapping", "misc", "perception", "sim", "unitree", "visualization", "web"] }, { name = "einops" }, { name = "gdown" }, { name = "googlemaps" }, @@ -2458,7 +2461,7 @@ requires-dist = [ { name = "cupy-cuda12x", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = "==13.6.0" }, { name = "cyclonedds", marker = "extra == 'dds'", specifier = ">=0.10.5" }, { name = "cyclonedds", marker = "extra == 'unitree-dds'", specifier = ">=0.10.5" }, - { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "grasp", "manipulation", "misc", "perception", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, + { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "grasp", "manipulation", "manipulation-toppra", "misc", "perception", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, { name = "dimos", extras = ["agents", "web", "perception", "visualization"], marker = "extra == 'base'" }, { name = "dimos", extras = ["base", "mapping"], marker = "extra == 'unitree'" }, { name = "dimos", extras = ["unitree"], marker = "extra == 'unitree-dds'" }, @@ -2529,7 +2532,8 @@ requires-dist = [ { name = "reportlab", marker = "extra == 'apriltag'", specifier = ">=4.5.0" }, { name = "rerun-sdk", specifier = "==0.32.0a1" }, { name = "rerun-sdk", marker = "extra == 'visualization'", specifier = "==0.32.0a1" }, - { name = "roboplan", extras = ["manipulation"], marker = "extra == 'manipulation'", specifier = ">=0.0.100" }, + { name = "roboplan", marker = "extra == 'manipulation'", specifier = ">=0.0.100" }, + { name = "roboplan", marker = "extra == 'manipulation-toppra'", specifier = ">=0.0.100" }, { name = "scipy", specifier = ">=1.15.1" }, { name = "sortedcontainers", specifier = "==2.4.0" }, { name = "sounddevice", marker = "extra == 'agents'" }, @@ -2560,7 +2564,7 @@ requires-dist = [ { name = "xarm-python-sdk", marker = "extra == 'manipulation'", specifier = ">=1.17.0" }, { name = "xarm-python-sdk", marker = "extra == 'misc'", specifier = ">=1.17.0" }, ] -provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "grasp", "cpu", "cuda", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] +provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "manipulation-toppra", "grasp", "cpu", "cuda", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] [package.metadata.requires-dev] autofix = [{ name = "ruff", specifier = "==0.14.3" }] @@ -2627,7 +2631,7 @@ project-deps = [ tests = [ { name = "chromadb", specifier = ">=1.0.0" }, { name = "coverage", specifier = ">=7.0" }, - { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu"] }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu", "manipulation-toppra"] }, { name = "dimos", extras = ["web", "visualization"] }, { name = "einops", specifier = ">=0.8.1" }, { name = "gdown", specifier = "==6.0.0" }, @@ -2673,7 +2677,7 @@ tests-self-hosted = [ { name = "chromadb", specifier = ">=1.0.0" }, { name = "coverage", specifier = ">=7.0" }, { name = "dimos", extras = ["agents", "perception", "manipulation", "sim", "unitree", "misc"] }, - { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu"] }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu", "manipulation-toppra"] }, { name = "dimos", extras = ["web", "visualization"] }, { name = "einops", specifier = ">=0.8.1" }, { name = "gdown", specifier = "==6.0.0" }, From 2004be6de6f95f101fbf62d938fb763b09e72836 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 27 Jun 2026 12:13:22 -0700 Subject: [PATCH 095/110] spec: archive agentic primitive --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../agentic-manipulation-primitives/spec.md | 0 .../tasks.md | 0 .../agentic-manipulation-primitives/spec.md | 53 +++++++++++++++++++ 6 files changed, 53 insertions(+) rename openspec/changes/{add-agentic-manipulation-primitives => archive/2026-06-27-add-agentic-manipulation-primitives}/.openspec.yaml (100%) rename openspec/changes/{add-agentic-manipulation-primitives => archive/2026-06-27-add-agentic-manipulation-primitives}/design.md (100%) rename openspec/changes/{add-agentic-manipulation-primitives => archive/2026-06-27-add-agentic-manipulation-primitives}/proposal.md (100%) rename openspec/changes/{add-agentic-manipulation-primitives => archive/2026-06-27-add-agentic-manipulation-primitives}/specs/agentic-manipulation-primitives/spec.md (100%) rename openspec/changes/{add-agentic-manipulation-primitives => archive/2026-06-27-add-agentic-manipulation-primitives}/tasks.md (100%) create mode 100644 openspec/specs/agentic-manipulation-primitives/spec.md diff --git a/openspec/changes/add-agentic-manipulation-primitives/.openspec.yaml b/openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/.openspec.yaml similarity index 100% rename from openspec/changes/add-agentic-manipulation-primitives/.openspec.yaml rename to openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/.openspec.yaml diff --git a/openspec/changes/add-agentic-manipulation-primitives/design.md b/openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/design.md similarity index 100% rename from openspec/changes/add-agentic-manipulation-primitives/design.md rename to openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/design.md diff --git a/openspec/changes/add-agentic-manipulation-primitives/proposal.md b/openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/proposal.md similarity index 100% rename from openspec/changes/add-agentic-manipulation-primitives/proposal.md rename to openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/proposal.md diff --git a/openspec/changes/add-agentic-manipulation-primitives/specs/agentic-manipulation-primitives/spec.md b/openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/specs/agentic-manipulation-primitives/spec.md similarity index 100% rename from openspec/changes/add-agentic-manipulation-primitives/specs/agentic-manipulation-primitives/spec.md rename to openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/specs/agentic-manipulation-primitives/spec.md diff --git a/openspec/changes/add-agentic-manipulation-primitives/tasks.md b/openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/tasks.md similarity index 100% rename from openspec/changes/add-agentic-manipulation-primitives/tasks.md rename to openspec/changes/archive/2026-06-27-add-agentic-manipulation-primitives/tasks.md diff --git a/openspec/specs/agentic-manipulation-primitives/spec.md b/openspec/specs/agentic-manipulation-primitives/spec.md new file mode 100644 index 0000000000..1cc235ead4 --- /dev/null +++ b/openspec/specs/agentic-manipulation-primitives/spec.md @@ -0,0 +1,53 @@ +## Purpose + +Define a simulator-independent agent-facing manipulation primitive facade and its validation path through the DimOS manipulation stack. + +## Requirements + +### Requirement: Universal agentic manipulation facade +The system SHALL provide an `AgenticManipulationModule` that exposes a universal agent-facing manipulation primitive surface through DimOS skills. + +#### Scenario: Facade delegates to manipulation provider +- **WHEN** a caller invokes a primitive skill on `AgenticManipulationModule` +- **THEN** the module MUST delegate the operation to an injected manipulation provider through a DimOS Spec/RPC contract + +#### Scenario: Facade remains simulator independent +- **WHEN** `AgenticManipulationModule` is imported or unit tested +- **THEN** the module MUST NOT require Robosuite, runtime sidecar clients, or benchmark-specific APIs + +### Requirement: Initial primitive skill surface +The system SHALL expose robot state, joint motion, open gripper, and close gripper as the initial agentic manipulation primitive skills. + +#### Scenario: Robot state primitive is available +- **WHEN** a caller invokes the robot state primitive +- **THEN** the system MUST return the injected manipulation provider's robot state result + +#### Scenario: Joint motion primitive is available +- **WHEN** a caller invokes the joint motion primitive with a target joint configuration +- **THEN** the system MUST forward the target to the injected manipulation provider's joint motion operation + +#### Scenario: Gripper primitives are available +- **WHEN** a caller invokes the open or close gripper primitive +- **THEN** the system MUST forward the command to the injected manipulation provider's gripper operation + +### Requirement: Simulator-free primitive tests +The system SHALL include default unit tests for the agentic manipulation facade that do not depend on Robosuite or other heavy simulator runtimes. + +#### Scenario: Default test execution +- **WHEN** the default Python test suite runs without Robosuite installed +- **THEN** the agentic manipulation primitive unit tests MUST be able to execute using a fake injected manipulation provider + +### Requirement: Script-hosted Robosuite API validation +The system SHALL provide a script-hosted Robosuite validation path that calls the `AgenticManipulationModule` API through the full DimOS manipulation stack. + +#### Scenario: Full stack validation +- **WHEN** the Robosuite validation script runs in an environment with the Robosuite sidecar dependencies available +- **THEN** it MUST construct a stack containing the Robosuite sidecar, benchmark runtime SHM adapter, `ControlCoordinator`, `ManipulationModule`, and `AgenticManipulationModule` + +#### Scenario: API smoke assertions +- **WHEN** the Robosuite validation script calls the agentic manipulation primitives +- **THEN** it MUST fail if robot state, joint motion, open gripper, or close gripper does not report success through the API path + +#### Scenario: Script-hosted artifacts +- **WHEN** the Robosuite validation script completes or fails +- **THEN** it MUST write artifacts describing the episode config, runtime description, resolved runtime plan, API call summary, motor trace, score when available, sidecar log, and cleanup status From 1fa365e1947910c31f60e176abac1cdb6703ec91 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 28 Jun 2026 22:41:12 -0700 Subject: [PATCH 096/110] feat: add linear tcp pose planning --- .gitignore | 2 + CONTEXT.md | 6 +- dimos/manipulation/manipulation_module.py | 147 +++++ .../planning/planners/rrt_planner.py | 36 +- .../planners/test_rrt_planner_selection.py | 34 +- dimos/manipulation/planning/spec/models.py | 26 - dimos/manipulation/planning/spec/protocols.py | 29 +- .../manipulation/planning/spec/test_models.py | 64 +- .../planning/world/roboplan_world.py | 564 +++++++++++++++--- dimos/manipulation/test_manipulation_unit.py | 122 ++++ dimos/manipulation/test_roboplan_world.py | 546 ++++++++++++++--- .../test_roboplan_world_integration.py | 138 +++++ dimos/manipulation/visualization/viser/gui.py | 54 +- .../manipulation/visualization/viser/state.py | 7 + .../viser/test_operation_worker.py | 7 + .../viser/test_viser_visualization.py | 96 +++ .../manipulation-linear-tcp-planning/spec.md | 81 +++ .../specs/roboplan-cartesian-planning/spec.md | 121 ++-- pyproject.toml | 2 + 19 files changed, 1754 insertions(+), 328 deletions(-) create mode 100644 dimos/manipulation/test_roboplan_world_integration.py create mode 100644 openspec/specs/manipulation-linear-tcp-planning/spec.md diff --git a/.gitignore b/.gitignore index 832f136c7c..d3300a06ca 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,5 @@ recording*.db # Benchmark demo output artifacts artifacts/ + +.codegraph/ diff --git a/CONTEXT.md b/CONTEXT.md index c97426f1df..e9decd4791 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,9 +13,13 @@ A planning group that represents coordinated motion across multiple selected pla _Avoid_: group combination, combined groups, multi-group plan **Auxiliary planning group**: -A selected planning group that may move as part of a plan but does not have its own task-space target in that plan. +A planning group included in a planning request that may move as part of the plan but does not have its own task-space target in that plan. _Avoid_: extra group, passive target group, unconstrained target +**Linear TCP path**: +A motion recipe where the tool center point follows a straight Cartesian segment from its start pose to its target pose. +_Avoid_: linear joint motion, linear motion + **Composite RoboPlan model**: A RoboPlan-facing robot model that represents multiple registered robot models as one planning scene. _Avoid_: combined URDF, merged robot scene diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 1be640c98f..d9038d593d 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -59,6 +59,8 @@ from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType from dimos.manipulation.planning.spec.models import ( + CartesianDelta, + CartesianPathMode, CollisionCheckResult, ForwardKinematicsResult, GeneratedPlan, @@ -607,6 +609,43 @@ def _plan_selected_path( self._state = ManipulationState.COMPLETED return True + def _finish_cartesian_plan( + self, + group_ids: tuple[PlanningGroupID, ...], + result: PlanningResult, + ) -> bool: + """Store a successful Cartesian planner result or report its failure.""" + if not result.is_success(): + detail = f": {result.message}" if result.message else "" + return self._fail(f"Planning failed: {result.status.name}{detail}") + self._store_generated_plan(group_ids, result) + self._state = ManipulationState.COMPLETED + return True + + def _resolve_group_ids_for_pose_targets( + self, + target_groups: Sequence[PlanningGroupID | PlanningGroup], + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup], + ) -> tuple[PlanningGroupID, ...]: + """Return selected planning group IDs with target groups before auxiliaries.""" + target_ids = tuple(planning_group_id_from_selector(group) for group in target_groups) + auxiliary_ids = tuple(planning_group_id_from_selector(group) for group in auxiliary_groups) + return tuple(dict.fromkeys((*target_ids, *auxiliary_ids))) + + def _selected_start_for_groups( + self, group_ids: tuple[PlanningGroupID, ...] + ) -> JointState | None: + """Resolve the selected global-joint start state for a group selection.""" + try: + start = self._selected_joint_state(group_ids) + except Exception as exc: + self._fail(f"Failed to resolve planning groups: {exc}") + return None + if start is None: + self._fail("No joint state") + return None + return start + def _dismiss_preview(self, group_ids: Sequence[PlanningGroupID]) -> None: """Hide the preview ghost if the world supports it.""" if self._world_monitor is None: @@ -892,6 +931,114 @@ def plan_to_pose_targets( return self._fail(f"IK failed: {ik.status.name}") return self._plan_selected_path(group_ids, start, ik.joint_state) + @rpc + def plan_linear_to_pose_targets( + self, + pose_targets: Mapping[PlanningGroupID | PlanningGroup, Pose], + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup] = (), + timeout: float = 10.0, + ) -> bool: + """Plan a linear TCP path to one or more absolute group pose targets.""" + if self._world_monitor is None or self._planner is None: + return False + if not pose_targets: + return self._fail("At least one pose target is required") + + stamped_targets = { + planning_group_id_from_selector(group): PoseStamped( + frame_id="world", + position=pose.position, + orientation=pose.orientation, + ) + for group, pose in pose_targets.items() + } + auxiliary_ids = tuple(planning_group_id_from_selector(group) for group in auxiliary_groups) + group_ids = self._resolve_group_ids_for_pose_targets( + tuple(pose_targets.keys()), auxiliary_groups + ) + if not self._begin_planning(): + return False + start = self._selected_start_for_groups(group_ids) + if start is None: + return False + + result = self._planner.plan_cartesian_path( + world=self._world_monitor.world, + selection=self._world_monitor.planning_groups.select(group_ids), + start=start, + pose_targets=stamped_targets, + auxiliary_groups=auxiliary_ids, + path_mode="linear", + timeout=timeout, + ) + return self._finish_cartesian_plan(group_ids, result) + + @rpc + def plan_relative_to_pose_targets( + self, + delta_targets: Mapping[PlanningGroupID | PlanningGroup, CartesianDelta], + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup] = (), + timeout: float = 10.0, + ) -> bool: + """Plan freely to one or more relative group pose deltas.""" + return self._plan_relative_pose_targets( + delta_targets=delta_targets, + auxiliary_groups=auxiliary_groups, + path_mode="free", + timeout=timeout, + ) + + @rpc + def plan_linear_relative_to_pose_targets( + self, + delta_targets: Mapping[PlanningGroupID | PlanningGroup, CartesianDelta], + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup] = (), + timeout: float = 10.0, + ) -> bool: + """Plan a linear TCP path to one or more relative group pose deltas.""" + return self._plan_relative_pose_targets( + delta_targets=delta_targets, + auxiliary_groups=auxiliary_groups, + path_mode="linear", + timeout=timeout, + ) + + def _plan_relative_pose_targets( + self, + delta_targets: Mapping[PlanningGroupID | PlanningGroup, CartesianDelta], + auxiliary_groups: Sequence[PlanningGroupID | PlanningGroup], + path_mode: CartesianPathMode, + timeout: float, + ) -> bool: + """Shared relative pose-target planner entrypoint.""" + if self._world_monitor is None or self._planner is None: + return False + if not delta_targets: + return self._fail("At least one relative pose target is required") + + target_ids = tuple(planning_group_id_from_selector(group) for group in delta_targets) + resolved_targets = { + planning_group_id_from_selector(group): delta for group, delta in delta_targets.items() + } + auxiliary_ids = tuple(planning_group_id_from_selector(group) for group in auxiliary_groups) + group_ids = tuple(dict.fromkeys((*target_ids, *auxiliary_ids))) + if not self._begin_planning(): + return False + start = self._selected_start_for_groups(group_ids) + if start is None: + return False + + result = self._planner.plan_relative_cartesian_path( + world=self._world_monitor.world, + selection=self._world_monitor.planning_groups.select(group_ids), + start=start, + delta_targets=resolved_targets, + auxiliary_groups=auxiliary_ids, + path_mode=path_mode, + timeout=timeout, + ) + return self._finish_cartesian_plan(group_ids, result) + @rpc def plan_to_joints(self, joints: JointState, robot_name: RobotName | None = None) -> bool: """Plan motion to joint config. Use preview_plan() then execute(). diff --git a/dimos/manipulation/planning/planners/rrt_planner.py b/dimos/manipulation/planning/planners/rrt_planner.py index c0e467f987..322fce47c4 100644 --- a/dimos/manipulation/planning/planners/rrt_planner.py +++ b/dimos/manipulation/planning/planners/rrt_planner.py @@ -20,6 +20,7 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field import time from typing import TYPE_CHECKING @@ -33,14 +34,17 @@ from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.enums import PlanningStatus from dimos.manipulation.planning.spec.models import ( - CartesianPlanningRequest, + CartesianDelta, + CartesianPathMode, JointPath, + PlanningGroupID, PlanningResult, RobotName, WorldRobotID, ) from dimos.manipulation.planning.spec.protocols import WorldSpec from dimos.manipulation.planning.utils.path_utils import compute_path_length +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState from dimos.utils.logging_config import setup_logger @@ -246,10 +250,34 @@ def plan_selected_joint_path( def plan_cartesian_path( self, world: WorldSpec, - request: CartesianPlanningRequest, + selection: PlanningGroupSelection, + start: JointState, + pose_targets: Mapping[PlanningGroupID, PoseStamped], + *, + auxiliary_groups: Sequence[PlanningGroupID] = (), + path_mode: CartesianPathMode = "free", + timeout: float = 10.0, + ) -> PlanningResult: + """Return explicit unsupported status for absolute Cartesian requests.""" + _ = (world, selection, start, pose_targets, auxiliary_groups, path_mode, timeout) + return _create_failure_result( + PlanningStatus.UNSUPPORTED, + "Cartesian planning is not supported by this planner", + ) + + def plan_relative_cartesian_path( + self, + world: WorldSpec, + selection: PlanningGroupSelection, + start: JointState, + delta_targets: Mapping[PlanningGroupID, CartesianDelta], + *, + auxiliary_groups: Sequence[PlanningGroupID] = (), + path_mode: CartesianPathMode = "free", + timeout: float = 10.0, ) -> PlanningResult: - """Return explicit unsupported status for Cartesian planner requests.""" - _ = (world, request) + """Return explicit unsupported status for relative Cartesian requests.""" + _ = (world, selection, start, delta_targets, auxiliary_groups, path_mode, timeout) return _create_failure_result( PlanningStatus.UNSUPPORTED, "Cartesian planning is not supported by this planner", diff --git a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py index a026335e35..4f75fec143 100644 --- a/dimos/manipulation/planning/planners/test_rrt_planner_selection.py +++ b/dimos/manipulation/planning/planners/test_rrt_planner_selection.py @@ -27,7 +27,7 @@ from dimos.manipulation.planning.planners.rrt_planner import RRTConnectPlanner from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import PlanningStatus -from dimos.manipulation.planning.spec.models import CartesianPlanningRequest +from dimos.manipulation.planning.spec.models import CartesianDelta from dimos.manipulation.planning.spec.protocols import WorldSpec from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState @@ -251,16 +251,34 @@ def test_plan_selected_joint_path_rejects_single_robot_subset_selection() -> Non def test_plan_cartesian_path_returns_unsupported_without_ik_fallback() -> None: group = _group("arm/manipulator", "arm", ("arm/joint1", "arm/joint2")) world = _SelectionWorld(robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}) + selection = _selection(group) + start = _joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]) result = RRTConnectPlanner().plan_cartesian_path( cast("WorldSpec", world), - CartesianPlanningRequest( - selection=_selection(group), - group_id="arm/manipulator", - start=_joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]), - target=PoseStamped(position=[0.1, 0.0, 0.0], orientation=[0.0, 0.0, 0.0, 1.0]), - target_mode="absolute", - ), + selection, + start, + {"arm/manipulator": PoseStamped(position=[0.1, 0.0, 0.0])}, + ) + + assert result.status == PlanningStatus.UNSUPPORTED + assert "Cartesian planning is not supported" in result.message + assert world.config_collision_names == [] + assert world.edge_collision_names == [] + assert world.coupled_collision_checks == 0 + + +def test_plan_relative_cartesian_path_returns_unsupported_without_ik_fallback() -> None: + group = _group("arm/manipulator", "arm", ("arm/joint1", "arm/joint2")) + world = _SelectionWorld(robot_configs={"robot_1": _robot_config("arm", ["joint1", "joint2"])}) + selection = _selection(group) + start = _joint_state(["arm/joint1", "arm/joint2"], [0.0, 0.0]) + + result = RRTConnectPlanner().plan_relative_cartesian_path( + cast("WorldSpec", world), + selection, + start, + {"arm/manipulator": CartesianDelta(translation=(0.1, 0.0, 0.0))}, ) assert result.status == PlanningStatus.UNSUPPORTED diff --git a/dimos/manipulation/planning/spec/models.py b/dimos/manipulation/planning/spec/models.py index d7a4033e0e..e3ff8d0a50 100644 --- a/dimos/manipulation/planning/spec/models.py +++ b/dimos/manipulation/planning/spec/models.py @@ -30,7 +30,6 @@ import numpy as np from numpy.typing import NDArray - from dimos.manipulation.planning.groups.models import PlanningGroupSelection from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.JointState import JointState @@ -87,9 +86,6 @@ class PlanningSceneInfo: ] """Status for a group-scoped forward-kinematics query.""" -CartesianTargetMode: TypeAlias = Literal["absolute", "relative"] -"""Mode describing whether a Cartesian target is an absolute pose or relative delta.""" - CartesianPathMode: TypeAlias = Literal["free", "linear"] """Mode describing requested Cartesian path semantics.""" @@ -107,28 +103,6 @@ class CartesianDelta: frame_id: str = "world" -@dataclass(frozen=True) -class CartesianPlanningRequest: - """Planner-native Cartesian path request. - - `selection` defines the selected global joints to plan and return. `group_id` - identifies the selected planning group's TCP/target frame whose pose should reach - or follow the Cartesian target. `start` contains exactly the selected global joints - in caller order. - """ - - selection: PlanningGroupSelection - group_id: PlanningGroupID - start: JointState - target: PoseStamped | CartesianDelta - target_mode: CartesianTargetMode - path_mode: CartesianPathMode = "free" - reference_frame: str = "world" - max_translation_step: float = 0.01 - max_rotation_step: float = 0.05 - timeout: float = 10.0 - - @dataclass(frozen=True) class CollisionCheckResult: """Result of a planning-world collision target check.""" diff --git a/dimos/manipulation/planning/spec/protocols.py b/dimos/manipulation/planning/spec/protocols.py index 85a9f1f15b..401d3b1b9e 100644 --- a/dimos/manipulation/planning/spec/protocols.py +++ b/dimos/manipulation/planning/spec/protocols.py @@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence from contextlib import AbstractContextManager import numpy as np @@ -32,7 +32,8 @@ from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.models import ( - CartesianPlanningRequest, + CartesianDelta, + CartesianPathMode, GeneratedPlan, IKResult, Obstacle, @@ -295,9 +296,29 @@ def plan_selected_joint_path( def plan_cartesian_path( self, world: WorldSpec, - request: CartesianPlanningRequest, + selection: PlanningGroupSelection, + start: JointState, + pose_targets: Mapping[PlanningGroupID, PoseStamped], + *, + auxiliary_groups: Sequence[PlanningGroupID] = (), + path_mode: CartesianPathMode = "free", + timeout: float = 10.0, + ) -> PlanningResult: + """Plan over absolute Cartesian TCP targets for a selected group set.""" + ... + + def plan_relative_cartesian_path( + self, + world: WorldSpec, + selection: PlanningGroupSelection, + start: JointState, + delta_targets: Mapping[PlanningGroupID, CartesianDelta], + *, + auxiliary_groups: Sequence[PlanningGroupID] = (), + path_mode: CartesianPathMode = "free", + timeout: float = 10.0, ) -> PlanningResult: - """Plan over a Cartesian TCP target for a selected planning group.""" + """Plan over relative Cartesian TCP deltas for a selected group set.""" ... def get_name(self) -> str: diff --git a/dimos/manipulation/planning/spec/test_models.py b/dimos/manipulation/planning/spec/test_models.py index 0836d9f8c6..eaa8448d43 100644 --- a/dimos/manipulation/planning/spec/test_models.py +++ b/dimos/manipulation/planning/spec/test_models.py @@ -20,69 +20,27 @@ import pytest -from dimos.manipulation.planning.groups.models import PlanningGroup, PlanningGroupSelection -from dimos.manipulation.planning.spec.models import CartesianDelta, CartesianPlanningRequest -from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped -from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.manipulation.planning.spec.models import CartesianDelta -GROUP = PlanningGroup( - id="arm/manipulator", - robot_name="arm", - group_name="manipulator", - joint_names=("arm/joint1", "arm/joint2"), - local_joint_names=("joint1", "joint2"), - base_link="base", - tip_link="tcp", -) -SELECTION = PlanningGroupSelection.from_groups((GROUP,)) -START = JointState({"name": ["arm/joint1", "arm/joint2"], "position": [0.0, 0.0]}) +def test_cartesian_delta_defaults_to_world_identity_delta() -> None: + delta = CartesianDelta() -def test_cartesian_planning_request_carries_absolute_pose_target() -> None: - target = PoseStamped(frame_id="world", position=[0.1, 0.2, 0.3]) - - request = CartesianPlanningRequest( - selection=SELECTION, - group_id="arm/manipulator", - start=START, - target=target, - target_mode="absolute", - ) - - assert request.target is target - assert request.target_mode == "absolute" - assert request.path_mode == "free" - assert request.reference_frame == "world" + assert delta.translation == (0.0, 0.0, 0.0) + assert delta.rotation_rpy == (0.0, 0.0, 0.0) + assert delta.frame_id == "world" -def test_cartesian_planning_request_carries_relative_delta_target() -> None: +def test_cartesian_delta_carries_relative_target_values() -> None: delta = CartesianDelta(translation=(0.1, 0.0, 0.0), rotation_rpy=(0.0, 0.0, 0.2)) - request = CartesianPlanningRequest( - selection=SELECTION, - group_id="arm/manipulator", - start=START, - target=delta, - target_mode="relative", - path_mode="linear", - ) + assert delta.translation == (0.1, 0.0, 0.0) + assert delta.rotation_rpy == (0.0, 0.0, 0.2) + assert delta.frame_id == "world" - assert request.target is delta - assert request.target_mode == "relative" - assert request.path_mode == "linear" - -def test_cartesian_models_are_frozen() -> None: +def test_cartesian_delta_is_frozen() -> None: delta = CartesianDelta() - request = CartesianPlanningRequest( - selection=SELECTION, - group_id="arm/manipulator", - start=START, - target=delta, - target_mode="relative", - ) with pytest.raises(FrozenInstanceError): delta.frame_id = "tool" # type: ignore[misc] - with pytest.raises(FrozenInstanceError): - request.path_mode = "linear" # type: ignore[misc] diff --git a/dimos/manipulation/planning/world/roboplan_world.py b/dimos/manipulation/planning/world/roboplan_world.py index 9cf93efb7d..94fe039f60 100644 --- a/dimos/manipulation/planning/world/roboplan_world.py +++ b/dimos/manipulation/planning/world/roboplan_world.py @@ -27,7 +27,7 @@ from dataclasses import dataclass, field, replace import importlib from itertools import combinations, pairwise -from math import atan2, sqrt +from math import atan2, ceil, sqrt from pathlib import Path import tempfile import time @@ -36,7 +36,7 @@ from xml.sax.saxutils import escape import numpy as np -from scipy.spatial.transform import Rotation as R +from scipy.spatial.transform import Rotation as R, Slerp try: import roboplan.core as roboplan_core @@ -60,9 +60,10 @@ from dimos.manipulation.planning.spec.enums import IKStatus, ObstacleType, PlanningStatus from dimos.manipulation.planning.spec.models import ( CartesianDelta, - CartesianPlanningRequest, + CartesianPathMode, IKResult, Obstacle, + PlanningGroupID, PlanningResult, WorldRobotID, ) @@ -88,6 +89,8 @@ _COMPOSITE_GROUP_PREFIX = "_dimos_composite" _CARTESIAN_POSITION_TOLERANCE = 1e-3 _CARTESIAN_ORIENTATION_TOLERANCE = 1e-2 +_LINEAR_CARTESIAN_TRANSLATION_STEP = 0.01 +_LINEAR_CARTESIAN_ROTATION_STEP = 0.05 @dataclass @@ -740,9 +743,15 @@ def plan_selected_joint_path( def plan_cartesian_path( self, world: WorldSpec, - request: CartesianPlanningRequest, + selection: PlanningGroupSelection, + start: JointState, + pose_targets: Mapping[PlanningGroupID, PoseStamped], + *, + auxiliary_groups: Sequence[PlanningGroupID] = (), + path_mode: CartesianPathMode = "free", + timeout: float = 10.0, ) -> PlanningResult: - """Plan a RoboPlan-native free Cartesian path to a selected TCP target.""" + """Plan a RoboPlan-backed free path to absolute Cartesian TCP targets.""" if world is not self: return PlanningResult( status=PlanningStatus.UNSUPPORTED, @@ -751,17 +760,30 @@ def plan_cartesian_path( self._require_finalized() start_time = time.time() - invalid = self._validate_cartesian_request(request) + invalid = self._validate_cartesian_request( + selection, + start, + pose_targets.keys(), + auxiliary_groups, + path_mode, + timeout, + ) if invalid is not None: invalid.planning_time = time.time() - start_time return invalid - if request.path_mode == "linear": - return PlanningResult( - status=PlanningStatus.UNSUPPORTED, - planning_time=time.time() - start_time, - message="Linear Cartesian planning is not supported by the RoboPlan adapter yet", + invalid = self._validate_absolute_cartesian_targets(pose_targets) + if invalid is not None: + invalid.planning_time = time.time() - start_time + return invalid + if path_mode == "linear": + return self._plan_linear_cartesian_path( + selection, + start, + pose_targets, + timeout, + start_time, ) - if roboplan_simple_ik is None: + if len(pose_targets) == 1 and roboplan_simple_ik is None: return PlanningResult( status=PlanningStatus.UNSUPPORTED, planning_time=time.time() - start_time, @@ -769,42 +791,50 @@ def plan_cartesian_path( ) try: - group = self._planning_groups.get(request.group_id) - group_data = self._group_data_for_selection_ids(request.selection.group_ids) + target_groups = self._target_groups_for_ids(pose_targets.keys()) + auxiliary_group_objects = self._target_groups_for_ids(auxiliary_groups) + group_data = self._group_data_for_selection_ids(selection.group_ids) with self.scratch_context() as ctx: - self._set_selection_state(ctx, request.selection, request.start) + self._set_selection_state(ctx, selection, start) self._set_scene_current_q(ctx) - target_pose = self._resolve_cartesian_target(ctx, request) - q_start = self._joint_state_to_native_selection_q( - request.selection, group_data, request.start - ) - q_goal = self._solve_cartesian_goal( - group, + q_start = self._joint_state_to_native_selection_q(selection, group_data, start) + q_goal_state = self._solve_cartesian_goal_state( + selection, group_data, q_start, - target_pose, - request.timeout, + start, + { + target_groups[group_id]: PoseStamped( + frame_id="world", + position=target.position, + orientation=target.orientation, + ) + for group_id, target in pose_targets.items() + }, + tuple(auxiliary_group_objects.values()), + timeout, + ) + q_goal = self._joint_state_to_native_selection_q( + selection, group_data, q_goal_state ) result = self._run_native_rrt( group_data.group_name, group_data.native_joint_names, q_start, q_goal, - request.timeout, + timeout, ) result_names, path_arrays = self._extract_native_path(result) path = self._native_path_to_public_selection_path( - request.selection, group_data, path_arrays, result_names + selection, group_data, path_arrays, result_names ) - if path and not self._selection_path_collision_free(request.selection, path): + if path and not self._selection_path_collision_free(selection, path): return PlanningResult( status=PlanningStatus.NO_SOLUTION, planning_time=time.time() - start_time, message="RoboPlan Cartesian planning failed: returned path is in collision", ) - if path and not self._cartesian_final_pose_matches( - request.selection, request.group_id, path[-1], target_pose - ): + if path and not self._cartesian_final_poses_match(selection, path[-1], pose_targets): return PlanningResult( status=PlanningStatus.NO_SOLUTION, planning_time=time.time() - start_time, @@ -836,6 +866,57 @@ def plan_cartesian_path( message="RoboPlan Cartesian path found", ) + def plan_relative_cartesian_path( + self, + world: WorldSpec, + selection: PlanningGroupSelection, + start: JointState, + delta_targets: Mapping[PlanningGroupID, CartesianDelta], + *, + auxiliary_groups: Sequence[PlanningGroupID] = (), + path_mode: CartesianPathMode = "free", + timeout: float = 10.0, + ) -> PlanningResult: + """Plan a RoboPlan-backed free path to relative Cartesian TCP deltas.""" + if world is not self: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan-native Cartesian planner requires its RoboPlanWorld instance", + ) + self._require_finalized() + + start_time = time.time() + invalid = self._validate_cartesian_request( + selection, + start, + delta_targets.keys(), + auxiliary_groups, + path_mode, + timeout, + ) + if invalid is not None: + invalid.planning_time = time.time() - start_time + return invalid + invalid = self._validate_relative_cartesian_targets(delta_targets) + if invalid is not None: + invalid.planning_time = time.time() - start_time + return invalid + + with self.scratch_context() as ctx: + self._set_selection_state(ctx, selection, start) + pose_targets = self._resolve_relative_cartesian_targets(ctx, delta_targets) + result = self.plan_cartesian_path( + world, + selection, + start, + pose_targets, + auxiliary_groups=auxiliary_groups, + path_mode=path_mode, + timeout=timeout, + ) + result.planning_time = time.time() - start_time + return result + def get_name(self) -> str: """Get planner name.""" return "RoboPlan" @@ -843,110 +924,347 @@ def get_name(self) -> str: # Internals def _validate_cartesian_request( - self, request: CartesianPlanningRequest + self, + selection: PlanningGroupSelection, + start: JointState, + target_ids_iterable: Iterable[PlanningGroupID], + auxiliary_groups: Sequence[PlanningGroupID], + path_mode: CartesianPathMode, + timeout: float, ) -> PlanningResult | None: - if request.target_mode not in ("absolute", "relative"): + target_ids = set(target_ids_iterable) + auxiliary_ids = set(auxiliary_groups) + if path_mode not in ("free", "linear"): return PlanningResult( status=PlanningStatus.INVALID_GOAL, - message=f"Unsupported Cartesian target_mode: {request.target_mode}", + message=f"Unsupported Cartesian path_mode: {path_mode}", ) - if request.path_mode not in ("free", "linear"): + if timeout <= 0.0: return PlanningResult( - status=PlanningStatus.INVALID_GOAL, - message=f"Unsupported Cartesian path_mode: {request.path_mode}", - ) - if request.reference_frame != "world": - return PlanningResult( - status=PlanningStatus.UNSUPPORTED, - message="RoboPlan Cartesian planning currently supports only world reference_frame", + status=PlanningStatus.INVALID_GOAL, message="timeout must be positive" ) - if request.max_translation_step <= 0.0: + if not target_ids: return PlanningResult( status=PlanningStatus.INVALID_GOAL, - message="max_translation_step must be positive", + message="At least one Cartesian target group is required", ) - if request.max_rotation_step <= 0.0: + if len(auxiliary_ids) != len(auxiliary_groups): return PlanningResult( status=PlanningStatus.INVALID_GOAL, - message="max_rotation_step must be positive", + message="auxiliary_groups must not contain duplicate group IDs", ) - if request.timeout <= 0.0: + overlap = target_ids & auxiliary_ids + if overlap: return PlanningResult( - status=PlanningStatus.INVALID_GOAL, message="timeout must be positive" + status=PlanningStatus.INVALID_GOAL, + message=f"Cartesian target groups overlap auxiliary groups: {sorted(overlap)}", ) - if request.selection.group_ids != (request.group_id,): + selected_ids = set(selection.group_ids) + coverage = target_ids | auxiliary_ids + if coverage != selected_ids: + missing = sorted(selected_ids - coverage) + extra = sorted(coverage - selected_ids) return PlanningResult( - status=PlanningStatus.UNSUPPORTED, - message="RoboPlan Cartesian planning currently supports exactly one selected TCP group", + status=PlanningStatus.INVALID_GOAL, + message=( + "Cartesian target groups plus auxiliary_groups must exactly cover " + f"selection.group_ids; missing={missing}, extra={extra}" + ), ) try: - group = self._planning_groups.get(request.group_id) + groups_by_id = self._target_groups_for_ids(target_ids | auxiliary_ids) except KeyError as exc: return PlanningResult(status=PlanningStatus.UNSUPPORTED, message=str(exc)) - if group.tip_link is None: - return PlanningResult( - status=PlanningStatus.INVALID_GOAL, - message=f"Planning group '{request.group_id}' has no pose target frame", - ) - unsupported = self._validate_supported_selection(request.selection) + for group_id in target_ids: + if groups_by_id[group_id].tip_link is None: + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message=f"Planning group '{group_id}' has no pose target frame", + ) + unsupported = self._validate_supported_selection(selection) if unsupported is not None: return unsupported try: - self._selection_positions_by_global(request.selection, request.start) + self._selection_positions_by_global(selection, start) except ValueError as exc: return PlanningResult(status=PlanningStatus.INVALID_START, message=str(exc)) + if path_mode == "linear" and len(target_ids) > 1: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="Linear Cartesian planning currently supports at most one target group", + ) + return None - if request.target_mode == "absolute": - if not isinstance(request.target, PoseStamped): + def _validate_absolute_cartesian_targets( + self, pose_targets: Mapping[PlanningGroupID, PoseStamped] + ) -> PlanningResult | None: + for group_id, target in pose_targets.items(): + if not isinstance(target, PoseStamped): return PlanningResult( status=PlanningStatus.INVALID_GOAL, - message="absolute Cartesian target_mode requires a PoseStamped target", + message=f"Absolute Cartesian target for '{group_id}' must be a PoseStamped", ) - if request.target.frame_id not in ("", "world"): + if target.frame_id not in ("", "world"): return PlanningResult( status=PlanningStatus.UNSUPPORTED, message="RoboPlan Cartesian planning currently supports only world-frame poses", ) - return None + return None - if not isinstance(request.target, CartesianDelta): - return PlanningResult( - status=PlanningStatus.INVALID_GOAL, - message="relative Cartesian target_mode requires a CartesianDelta target", - ) - if request.target.frame_id != "world": - return PlanningResult( - status=PlanningStatus.UNSUPPORTED, - message="RoboPlan Cartesian planning currently supports only world-frame deltas", - ) + def _validate_relative_cartesian_targets( + self, delta_targets: Mapping[PlanningGroupID, CartesianDelta] + ) -> PlanningResult | None: + for group_id, target in delta_targets.items(): + if not isinstance(target, CartesianDelta): + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message=f"Relative Cartesian target for '{group_id}' must be a CartesianDelta", + ) + if target.frame_id != "world": + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="RoboPlan Cartesian planning currently supports only world-frame deltas", + ) return None - def _resolve_cartesian_target( - self, ctx: RoboPlanContext, request: CartesianPlanningRequest + def _target_groups_for_ids( + self, group_ids: Iterable[PlanningGroupID] + ) -> dict[PlanningGroupID, PlanningGroup]: + return {group_id: self._planning_groups.get(group_id) for group_id in group_ids} + + def _resolve_relative_cartesian_targets( + self, + ctx: RoboPlanContext, + delta_targets: Mapping[PlanningGroupID, CartesianDelta], + ) -> dict[PlanningGroupID, PoseStamped]: + return { + group_id: self._resolve_relative_cartesian_target(ctx, group_id, delta) + for group_id, delta in delta_targets.items() + } + + def _resolve_relative_cartesian_target( + self, ctx: RoboPlanContext, group_id: PlanningGroupID, delta: CartesianDelta ) -> PoseStamped: - if request.target_mode == "absolute": - if not isinstance(request.target, PoseStamped): - raise ValueError("absolute Cartesian target_mode requires a PoseStamped target") - return PoseStamped( - frame_id="world", - position=request.target.position, - orientation=request.target.orientation, - ) - - if not isinstance(request.target, CartesianDelta): - raise ValueError("relative Cartesian target_mode requires a CartesianDelta target") - start_pose = self.get_group_ee_pose(ctx, request.group_id) + start_pose = self.get_group_ee_pose(ctx, group_id) start_matrix = pose_to_matrix(start_pose) target_matrix = start_matrix.copy() - target_matrix[:3, 3] = start_matrix[:3, 3] + np.asarray( - request.target.translation, dtype=np.float64 - ) - delta_rotation = R.from_euler("xyz", request.target.rotation_rpy).as_matrix() + target_matrix[:3, 3] = start_matrix[:3, 3] + np.asarray(delta.translation, dtype=np.float64) + delta_rotation = R.from_euler("xyz", delta.rotation_rpy).as_matrix() target_matrix[:3, :3] = delta_rotation @ start_matrix[:3, :3] pose = matrix_to_pose(target_matrix) return PoseStamped(frame_id="world", position=pose.position, orientation=pose.orientation) + def _plan_linear_cartesian_path( + self, + selection: PlanningGroupSelection, + start: JointState, + pose_targets: Mapping[PlanningGroupID, PoseStamped], + timeout: float, + start_time: float, + ) -> PlanningResult: + if len(pose_targets) != 1: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + planning_time=time.time() - start_time, + message="Linear Cartesian planning currently supports at most one target group", + ) + try: + optimal_ik = self._load_optimal_ik() + except ImportError as exc: + return PlanningResult( + status=PlanningStatus.UNSUPPORTED, + planning_time=time.time() - start_time, + message=f"RoboPlan linear Cartesian planning requires Oink IK: {exc}", + ) + + group_id, target_pose = next(iter(pose_targets.items())) + try: + group = self._target_groups_for_ids((group_id,))[group_id] + group_data = self._group_data_for_selection_ids(selection.group_ids) + with self.scratch_context() as ctx: + self._set_selection_state(ctx, selection, start) + start_pose = self.get_group_ee_pose(ctx, group_id) + waypoints = self._linear_cartesian_waypoints(start_pose, target_pose) + q_current = self._joint_state_to_native_selection_q(selection, group_data, start) + path_arrays = [q_current.copy()] + for waypoint in waypoints[1:]: + q_current = self._solve_linear_oink_waypoint( + optimal_ik, + selection, + group, + group_data, + q_current, + waypoint, + ) + path_arrays.append(q_current.copy()) + path = self._native_path_to_public_selection_path( + selection, group_data, path_arrays, None + ) + except ValueError as exc: + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message=f"RoboPlan linear Cartesian planning failed: {exc}", + ) + except Exception as exc: + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message=f"RoboPlan linear Cartesian planning failed: {exc}", + ) + if not path: + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message="RoboPlan linear Cartesian planning failed: returned an empty path", + ) + if not self._selection_path_collision_free(selection, path): + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message="RoboPlan linear Cartesian planning failed: returned path is in collision", + ) + if not self._cartesian_final_poses_match(selection, path[-1], pose_targets): + return PlanningResult( + status=PlanningStatus.NO_SOLUTION, + planning_time=time.time() - start_time, + message="RoboPlan linear Cartesian planning failed: final TCP pose missed target", + ) + return PlanningResult( + status=PlanningStatus.SUCCESS, + path=path, + planning_time=time.time() - start_time, + path_length=compute_path_length(path), + message="RoboPlan linear Cartesian path found", + ) + + def _solve_linear_oink_waypoint( + self, + optimal_ik: Any, + selection: PlanningGroupSelection, + group: PlanningGroup, + group_data: _RoboPlanGroupData, + q_seed: NDArray[np.float64], + target_pose: PoseStamped, + ) -> NDArray[np.float64]: + scene = self._require_scene() + oink = optimal_ik.Oink(scene, group_name=group_data.group_name) + tasks = self._make_oink_frame_tasks( + optimal_ik, + oink, + scene, + {group: target_pose}, + _CARTESIAN_POSITION_TOLERANCE, + _CARTESIAN_ORIENTATION_TOLERANCE, + ) + constraints = self._make_oink_constraints(optimal_ik, oink, group_data) + lower_limits, upper_limits = self._selection_native_limits(selection, group_data) + q_candidate = np.asarray(q_seed, dtype=np.float64).copy() + iteration_limit = max(1, self._kinematics_config.max_iterations) + with self.scratch_context() as ctx: + for iteration in range(iteration_limit): + result = self._maybe_return_converged_oink_result( + ctx, + selection, + group_data, + q_candidate, + {group: target_pose}, + _CARTESIAN_POSITION_TOLERANCE, + _CARTESIAN_ORIENTATION_TOLERANCE, + iteration, + ) + if result.is_success(): + return q_candidate + if result.status == IKStatus.COLLISION: + raise ValueError(result.message) + + self._set_scene_group_joint_positions(scene, group_data.group_name, q_candidate) + delta_q = self._solve_oink_delta(oink, group_data, scene, tasks, constraints) + q_candidate = self._integrate_native_selection_q( + oink, group_data, q_candidate, delta_q + ) + q_candidate = np.clip(q_candidate, lower_limits, upper_limits) + + result = self._maybe_return_converged_oink_result( + ctx, + selection, + group_data, + q_candidate, + {group: target_pose}, + _CARTESIAN_POSITION_TOLERANCE, + _CARTESIAN_ORIENTATION_TOLERANCE, + iteration_limit, + ) + if result.is_success(): + return q_candidate + raise ValueError(result.message) + + def _linear_cartesian_waypoints( + self, start_pose: PoseStamped, target_pose: PoseStamped + ) -> list[PoseStamped]: + start_matrix = pose_to_matrix(start_pose) + target_matrix = pose_to_matrix(target_pose) + translation_delta = target_matrix[:3, 3] - start_matrix[:3, 3] + translation_distance = float(np.linalg.norm(translation_delta)) + start_rotation = R.from_matrix(start_matrix[:3, :3]) + target_rotation = R.from_matrix(target_matrix[:3, :3]) + rotation_distance = float((target_rotation * start_rotation.inv()).magnitude()) + step_count = max( + 1, + ceil(translation_distance / _LINEAR_CARTESIAN_TRANSLATION_STEP), + ceil(rotation_distance / _LINEAR_CARTESIAN_ROTATION_STEP), + ) + slerp = Slerp([0.0, 1.0], R.from_matrix([start_matrix[:3, :3], target_matrix[:3, :3]])) + waypoints: list[PoseStamped] = [] + for step in range(step_count + 1): + alpha = step / step_count + matrix = np.eye(4, dtype=np.float64) + matrix[:3, 3] = start_matrix[:3, 3] + alpha * translation_delta + matrix[:3, :3] = slerp([alpha]).as_matrix()[0] + pose = matrix_to_pose(matrix) + waypoints.append( + PoseStamped(frame_id="world", position=pose.position, orientation=pose.orientation) + ) + return waypoints + + def _solve_cartesian_goal_state( + self, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + q_start: NDArray[np.float64], + start: JointState, + pose_targets: Mapping[PlanningGroup, PoseStamped], + auxiliary_groups: Sequence[PlanningGroup], + timeout: float, + ) -> JointState: + if len(pose_targets) == 1: + group, target_pose = next(iter(pose_targets.items())) + q_goal = self._solve_cartesian_goal(group, group_data, q_start, target_pose, timeout) + return self._native_selection_q_to_joint_state(selection, group_data, q_goal) + + ordered_pose_targets = { + group: pose_targets[group] for group in selection.groups if group in pose_targets + } + ordered_auxiliary_groups = tuple( + group for group in selection.groups if group in set(auxiliary_groups) + ) + ik_result = self.solve_pose_targets( + self, + ordered_pose_targets, + auxiliary_groups=ordered_auxiliary_groups, + seed=start, + position_tolerance=_CARTESIAN_POSITION_TOLERANCE, + orientation_tolerance=_CARTESIAN_ORIENTATION_TOLERANCE, + ) + if not ik_result.is_success() or ik_result.joint_state is None: + raise ValueError(f"RoboPlan Cartesian IK failed: {ik_result.message}") + positions_by_global = self._selection_positions_by_global(selection, ik_result.joint_state) + return JointState( + name=list(selection.joint_names), + position=[positions_by_global[name] for name in selection.joint_names], + ) + def _solve_cartesian_goal( self, group: PlanningGroup, @@ -963,7 +1281,7 @@ def _solve_cartesian_goal( raise ValueError("roboplan.simple_ik does not expose SimpleIk and SimpleIkOptions") options = options_cls() - self._configure_simple_ik_options(options, timeout) + self._configure_simple_ik_options(options, timeout, group_data.group_name) solver = solver_cls(self._require_scene(), options) goal = self._to_native_cartesian_configuration(group, target_pose) start_config = self._to_native_joint_configuration(group_data.native_joint_names, q_start) @@ -981,8 +1299,11 @@ def _solve_cartesian_goal( solution_config = result return self._native_joint_configuration_to_q(group_data.native_joint_names, solution_config) - def _configure_simple_ik_options(self, options: Any, timeout: float) -> None: + def _configure_simple_ik_options( + self, options: Any, timeout: float, group_name: str | None = None + ) -> None: for name, value in ( + *((("group_name", group_name),) if group_name is not None else ()), ("max_time", timeout), ("timeout", timeout), ("max_solve_time", timeout), @@ -1005,7 +1326,7 @@ def _to_native_cartesian_configuration( self._get_robot(self._robot_ids_by_name[group.robot_name]).config, group.tip_link ) matrix = pose_to_matrix(target_pose) - for args in ((frame_name, matrix), (matrix, frame_name)): + for args in (("", frame_name, matrix), (frame_name, matrix), (matrix, frame_name)): try: return config_cls(*args) except TypeError: @@ -1014,9 +1335,12 @@ def _to_native_cartesian_configuration( for name, value in ( ("frame_name", frame_name), ("frame", frame_name), + ("tip_frame", frame_name), + ("base_frame", ""), ("matrix", matrix), ("pose", matrix), ("transform", matrix), + ("tform", matrix), ): try: setattr(config, name, value) @@ -1068,6 +1392,17 @@ def _cartesian_final_pose_matches( and rotation_error <= _CARTESIAN_ORIENTATION_TOLERANCE ) + def _cartesian_final_poses_match( + self, + selection: PlanningGroupSelection, + final_state: JointState, + target_poses: Mapping[PlanningGroupID, PoseStamped], + ) -> bool: + return all( + self._cartesian_final_pose_matches(selection, group_id, final_state, target_pose) + for group_id, target_pose in target_poses.items() + ) + def _ik_failure( self, status: IKStatus, @@ -1109,7 +1444,10 @@ def _make_oink_frame_tasks( raise ValueError(f"Planning group '{group.id}' has no pose target frame") native_map = self._native_names_by_robot[group.robot_name] target = roboplan_core.CartesianConfiguration() - target.base_frame = native_map.link(group.base_link) + # DimOS public Cartesian pose targets are absolute world-frame PoseStamped + # values. Oink interprets an empty base frame as world, matching the + # target.tform matrix below even for robot-scoped groups in composite scenes. + target.base_frame = "" target.tip_frame = native_map.link(group.tip_link) target.tform = pose_to_matrix(target_pose) options = self._make_oink_frame_task_options( @@ -1354,6 +1692,33 @@ def _set_scene_joint_positions(self, scene: Any, q: NDArray[np.float64]) -> None if setter is not None: setter(np.asarray(q, dtype=np.float64)) + def _set_scene_group_joint_positions( + self, scene: Any, group_name: str, q: NDArray[np.float64] + ) -> None: + setter = getattr(scene, "setJointPositions", None) + if setter is None: + raise ValueError("RoboPlan Scene does not expose setJointPositions") + q_array = np.asarray(q, dtype=np.float64) + try: + setter(group_name, q_array) + return + except TypeError: + pass + try: + setter(q_array, group_name) + return + except TypeError: + pass + + to_full = getattr(scene, "toFullJointPositions", None) + if to_full is None: + raise ValueError( + "RoboPlan Scene does not expose a group-aware joint state setter " + "or toFullJointPositions" + ) + full_q = np.asarray(to_full(group_name, q_array), dtype=np.float64) + setter(full_q) + def _solve_oink_delta( self, oink: Any, @@ -1399,6 +1764,17 @@ def _scatter_oink_delta( delta_full[index_by_native[native_name]] = float(value) return delta_full + def _integrate_native_selection_q( + self, + oink: Any, + group_data: _RoboPlanGroupData, + native_q: NDArray[np.float64], + delta_q: NDArray[np.float64], + ) -> NDArray[np.float64]: + delta_full_q = self._scatter_oink_delta(oink, group_data, delta_q) + delta_native_q = self._native_selection_q_from_full_q(group_data, delta_full_q) + return np.asarray(native_q, dtype=np.float64) + delta_native_q + def _integrate_scene_q( self, scene: Any, q: NDArray[np.float64], delta_q: NDArray[np.float64] ) -> NDArray[np.float64]: diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 833feb029b..6e85a5d3ac 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -34,9 +34,11 @@ from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus, PlanningStatus from dimos.manipulation.planning.spec.models import ( + CartesianDelta, CollisionCheckResult, GeneratedPlan, IKResult, + PlanningResult, PlanningSceneInfo, ) from dimos.manipulation.planning.spec.protocols import VisualizationSpec @@ -259,6 +261,7 @@ def test_kinematics_config_is_passed_to_factory( planning_initialization.mock_planning_specs.assert_called_once_with( world=planning_initialization.mock_world, + world_backend="drake", planner_name="rrt_connect", kinematics_name=None, kinematics=kinematics, @@ -278,6 +281,7 @@ def test_legacy_kinematics_name_still_selects_backend( planning_initialization.mock_planning_specs.assert_called_once_with( world=planning_initialization.mock_world, + world_backend="drake", planner_name="rrt_connect", kinematics_name="pink", kinematics=module.config.kinematics, @@ -673,6 +677,14 @@ def _make_generated_plan(group_ids: tuple[str, ...], *points: list[float]) -> Ge ) +def _successful_planning_result(*points: list[float]) -> PlanningResult: + return PlanningResult( + path=[JointState(name=["left/j1", "left/j2"], position=list(point)) for point in points], + status=PlanningStatus.SUCCESS, + message="ok", + ) + + def _trajectory_generator() -> MagicMock: generator = MagicMock() generator.generate.side_effect = lambda positions: JointTrajectory( @@ -968,6 +980,116 @@ def test_preview_plan_returns_false_for_missing_inputs(self): assert module.preview_plan() is False +class TestLinearTcpPosePlanning: + def test_plan_linear_to_pose_targets_calls_cartesian_planner(self): + config = _make_robot_config("left", ["j1", "j2"], "task") + module = _make_module_with_monitor(config) + module._planner = MagicMock() + module._world_monitor.world = MagicMock() + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1", "j2"], position=[0.0, 0.0] + ) + module._planner.plan_cartesian_path.return_value = _successful_planning_result( + [0.0, 0.0], [0.2, 0.3] + ) + + pose = Pose(position=Vector3(0.1, 0.2, 0.3), orientation=Quaternion()) + ok = module.plan_linear_to_pose_targets({"left/manipulator": pose}, timeout=2.5) + + assert ok is True + assert module._state == ManipulationState.COMPLETED + assert module._last_plan is not None + assert module._last_plan.group_ids == ("left/manipulator",) + call = module._planner.plan_cartesian_path.call_args + assert call.kwargs["world"] is module._world_monitor.world + assert call.kwargs["selection"].group_ids == ("left/manipulator",) + assert call.kwargs["start"].name == ["left/j1", "left/j2"] + assert tuple(call.kwargs["pose_targets"]) == ("left/manipulator",) + assert call.kwargs["auxiliary_groups"] == () + assert call.kwargs["path_mode"] == "linear" + assert call.kwargs["timeout"] == 2.5 + + @pytest.mark.parametrize( + ("method_name", "expected_path_mode"), + [ + ("plan_relative_to_pose_targets", "free"), + ("plan_linear_relative_to_pose_targets", "linear"), + ], + ) + def test_relative_pose_target_methods_call_relative_cartesian_planner( + self, + method_name: str, + expected_path_mode: str, + ): + config = _make_robot_config("left", ["j1", "j2"], "task") + module = _make_module_with_monitor(config) + module._planner = MagicMock() + module._world_monitor.world = MagicMock() + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1", "j2"], position=[0.0, 0.0] + ) + module._planner.plan_relative_cartesian_path.return_value = _successful_planning_result( + [0.0, 0.0], [0.1, 0.1] + ) + delta = CartesianDelta(translation=(0.1, 0.0, 0.0)) + + ok = getattr(module, method_name)({"left/manipulator": delta}, timeout=3.0) + + assert ok is True + assert module._last_plan is not None + assert module._last_plan.group_ids == ("left/manipulator",) + call = module._planner.plan_relative_cartesian_path.call_args + assert call.kwargs["selection"].group_ids == ("left/manipulator",) + assert call.kwargs["start"].name == ["left/j1", "left/j2"] + assert call.kwargs["delta_targets"] == {"left/manipulator": delta} + assert call.kwargs["auxiliary_groups"] == () + assert call.kwargs["path_mode"] == expected_path_mode + assert call.kwargs["timeout"] == 3.0 + + def test_explicit_cartesian_method_reports_planner_failure_without_fallback(self): + config = _make_robot_config("left", ["j1", "j2"], "task") + module = _make_module_with_monitor(config) + module._planner = MagicMock() + module._world_monitor.world = MagicMock() + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1", "j2"], position=[0.0, 0.0] + ) + module._planner.plan_cartesian_path.return_value = PlanningResult( + status=PlanningStatus.UNSUPPORTED, + message="linear unsupported", + ) + pose = Pose(position=Vector3(0.1, 0.0, 0.0), orientation=Quaternion()) + + assert module.plan_linear_to_pose_targets({"left/manipulator": pose}) is False + + assert module._state == ManipulationState.FAULT + assert "UNSUPPORTED" in module._error_message + assert "linear unsupported" in module._error_message + module._planner.plan_selected_joint_path.assert_not_called() + + def test_existing_plan_to_pose_targets_stays_ik_then_joint_plan(self, mocker: MockerFixture): + config = _make_robot_config("left", ["j1", "j2"], "task") + module = _make_module_with_monitor(config) + module._planner = MagicMock() + module._kinematics = MagicMock() + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["j1", "j2"], position=[0.0, 0.0] + ) + ik = IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState(name=["left/j1", "left/j2"], position=[0.2, 0.3]), + ) + inverse_kinematics = mocker.patch.object(module, "inverse_kinematics", return_value=ik) + plan_selected_path = mocker.patch.object(module, "_plan_selected_path", return_value=True) + pose = Pose(position=Vector3(0.1, 0.0, 0.0), orientation=Quaternion()) + + assert module.plan_to_pose_targets({"left/manipulator": pose}) is True + + inverse_kinematics.assert_called_once() + plan_selected_path.assert_called_once() + module._planner.plan_cartesian_path.assert_not_called() + + class TestGeneratedPlanProjection: def test_selected_joint_state_accepts_local_current_state_names(self): config = _make_robot_config("left", ["j1", "j2"], "task") diff --git a/dimos/manipulation/test_roboplan_world.py b/dimos/manipulation/test_roboplan_world.py index 1f9a9fb003..42a8009d51 100644 --- a/dimos/manipulation/test_roboplan_world.py +++ b/dimos/manipulation/test_roboplan_world.py @@ -34,7 +34,6 @@ from dimos.manipulation.planning.spec.enums import ObstacleType, PlanningStatus from dimos.manipulation.planning.spec.models import ( CartesianDelta, - CartesianPlanningRequest, Obstacle, ) from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -59,9 +58,30 @@ def __init__(self, joint_names: list[str], positions: list[np.ndarray]) -> None: class FakeCartesianConfiguration: - def __init__(self, frame_name: str = "", matrix: np.ndarray | None = None) -> None: - self.frame_name = frame_name - self.matrix = np.asarray(matrix if matrix is not None else np.eye(4), dtype=np.float64) + def __init__(self, *args: object) -> None: + self.base_frame = "" + self.tip_frame = "" + self.frame_name = "" + self.matrix = np.eye(4, dtype=np.float64) + self.tform = self.matrix + if len(args) == 0: + return + if len(args) == 2: + frame_name, matrix = args + self.tip_frame = str(frame_name) + self.frame_name = str(frame_name) + self.matrix = np.asarray(matrix, dtype=np.float64) + self.tform = self.matrix + return + if len(args) == 3: + base_frame, tip_frame, matrix = args + self.base_frame = str(base_frame) + self.tip_frame = str(tip_frame) + self.frame_name = str(tip_frame) + self.matrix = np.asarray(matrix, dtype=np.float64) + self.tform = self.matrix + return + raise TypeError("Unsupported FakeCartesianConfiguration constructor") class FakeJointGroupInfo: @@ -74,12 +94,19 @@ class FakeScene: position_limits_lower: ClassVar[list[float]] = [-1.0, -2.0] position_limits_upper: ClassVar[list[float]] = [1.0, 2.0] fk_rotation: ClassVar[np.ndarray | None] = None + fail_unnamed_set_after_first: ClassVar[bool] = False + reject_unnamed_set_position_size: ClassVar[int | None] = None + reject_empty_group_to_full_size: ClassVar[int | None] = None + reject_group_set_joint_positions: ClassVar[bool] = False + group_to_full_size: ClassVar[int | None] = None + set_joint_position_calls: ClassVar[list[tuple[str | None, list[float]]]] = [] def __init__(self, *args: Any) -> None: self.constructor_args = args self.models: list[tuple[str, str, dict[str, str]]] = [] self.geometry: dict[str, np.ndarray] = {} self.current_positions: np.ndarray | None = None + self._unnamed_set_joint_positions_calls = 0 self.joint_groups = self._parse_joint_groups(args[2] if len(args) > 2 else None) def _parse_joint_groups(self, srdf_path: Any) -> dict[str, list[str]]: @@ -117,11 +144,59 @@ def getJointGroupInfo(self, name: str) -> FakeJointGroupInfo: return FakeJointGroupInfo(self.joint_groups[name]) def toFullJointPositions(self, group_name: str, q: np.ndarray) -> np.ndarray: - _ = group_name + if ( + group_name == "" + and self.reject_empty_group_to_full_size is not None + and len(np.asarray(q)) != self.reject_empty_group_to_full_size + ): + raise ValueError( + "Failed to get full joint positions: Joint group '' has " + f"nq={self.reject_empty_group_to_full_size} but the input positions " + f"is of size {len(np.asarray(q))}" + ) + if group_name and self.group_to_full_size is not None: + q_array = np.asarray(q, dtype=np.float64) + full_q = np.zeros(self.group_to_full_size, dtype=np.float64) + full_q[: len(q_array)] = q_array + return full_q return q - def setJointPositions(self, q: np.ndarray) -> None: - self.current_positions = np.asarray(q, dtype=np.float64) + def setJointPositions(self, *args: object) -> None: + if len(args) == 1: + group_name = None + q = args[0] + self._unnamed_set_joint_positions_calls += 1 + if ( + self.reject_unnamed_set_position_size is not None + and len(np.asarray(q)) != self.reject_unnamed_set_position_size + ): + raise ValueError( + "setJointPositions: expected " + f"{self.reject_unnamed_set_position_size} configuration values " + f"(model.nq), got {len(np.asarray(q))}. For robots with mimic joints, " + "use the mimic-enabled model layout (not the expanded URDF DOF count)." + ) + if self.fail_unnamed_set_after_first and self._unnamed_set_joint_positions_calls > 1: + raise ValueError( + "Failed to get full joint positions: Joint group '' has nq=14 " + f"but the input positions is of size {len(np.asarray(q))}" + ) + elif len(args) == 2: + if self.reject_group_set_joint_positions: + raise TypeError("group-aware setJointPositions overload is unavailable") + group_name = str(args[0]) + q = args[1] + expected_joint_names = self.joint_groups.get(group_name) + if expected_joint_names is not None and len(np.asarray(q)) != len(expected_joint_names): + raise ValueError( + f"Joint group '{group_name}' has nq={len(expected_joint_names)} " + f"but the input positions is of size {len(np.asarray(q))}" + ) + else: + raise TypeError("setJointPositions expects q or group_name, q") + q_array = np.asarray(q, dtype=np.float64) + self.current_positions = q_array + self.set_joint_position_calls.append((group_name, q_array.tolist())) def addBoxGeometry( self, obstacle_id: str, width: float, height: float, depth: float, matrix: np.ndarray @@ -189,6 +264,7 @@ def plan( class FakeSimpleIkOptions: def __init__(self) -> None: + self.group_name = "" self.max_time = 0.0 self.timeout = 0.0 self.max_solve_time = 0.0 @@ -199,6 +275,7 @@ class FakeSimpleIk: last_options: ClassVar[FakeSimpleIkOptions | None] = None last_goal: ClassVar[FakeCartesianConfiguration | None] = None last_start: ClassVar[FakeJointConfiguration | None] = None + goals: ClassVar[list[FakeCartesianConfiguration]] = [] def __init__(self, scene: FakeScene, options: FakeSimpleIkOptions) -> None: self.scene = scene @@ -212,8 +289,10 @@ def solveIk( solution: FakeJointConfiguration, ) -> bool: _ = self.scene + self.scene.toFullJointPositions(self.options.group_name, start.positions) FakeSimpleIk.last_goal = goal FakeSimpleIk.last_start = start + FakeSimpleIk.goals.append(goal) joint_count = len(start.joint_names) if joint_count == 0: return False @@ -222,9 +301,78 @@ def solveIk( return True +class FakeFrameTaskOptions: + def __init__(self, **kwargs: object) -> None: + for name, value in kwargs.items(): + setattr(self, name, value) + + +class FakeFrameTask: + targets: ClassVar[list[FakeCartesianConfiguration]] = [] + + def __init__( + self, + oink: FakeOink, + scene: FakeScene, + target: FakeCartesianConfiguration, + options: FakeFrameTaskOptions, + ) -> None: + _ = (oink, scene, options) + self.target = target + self.targets.append(target) + + +class FakePositionLimit: + def __init__(self, oink: FakeOink, gain: float = 1.0) -> None: + _ = (oink, gain) + + +class FakeVelocityLimit: + def __init__(self, oink: FakeOink, dt: float, v_max: np.ndarray) -> None: + _ = (oink, dt, v_max) + + +class FakeOink: + last_group_name: ClassVar[str | None] = None + solve_calls: ClassVar[int] = 0 + + def __init__(self, scene: FakeScene, group_name: str = "") -> None: + self.scene = scene + self.group_name = group_name + FakeOink.last_group_name = group_name + + def solveIk( + self, + scene: FakeScene, + tasks: list[FakeFrameTask], + constraints: list[object], + barriers: list[object], + delta_q: np.ndarray, + regularization: float = 0.0, + ) -> None: + _ = (scene, constraints, barriers, regularization) + FakeOink.solve_calls += 1 + target_x = float(tasks[0].target.tform[0, 3]) + current_q = np.asarray(self.scene.current_positions, dtype=np.float64) + current_x = float(np.sum(current_q)) if current_q.size else 0.0 + delta_q[:] = (target_x - current_x) / max(1, len(delta_q)) + + def _install_fake_roboplan( - monkeypatch: pytest.MonkeyPatch, *, include_simple_ik: bool = False + monkeypatch: pytest.MonkeyPatch, + *, + include_simple_ik: bool = False, + include_optimal_ik: bool = False, ) -> None: + FakeScene.fail_unnamed_set_after_first = False + FakeScene.reject_unnamed_set_position_size = None + FakeScene.reject_empty_group_to_full_size = None + FakeScene.reject_group_set_joint_positions = False + FakeScene.group_to_full_size = None + FakeScene.set_joint_position_calls = [] + FakeFrameTask.targets = [] + FakeOink.last_group_name = None + FakeOink.solve_calls = 0 roboplan_pkg = ModuleType("roboplan") roboplan_pkg.__path__ = [] # type: ignore[attr-defined] core = ModuleType("roboplan.core") @@ -255,11 +403,21 @@ def has_collisions_along_path( monkeypatch.setitem(sys.modules, "roboplan", roboplan_pkg) monkeypatch.setitem(sys.modules, "roboplan.core", core) monkeypatch.setitem(sys.modules, "roboplan.rrt", rrt) + monkeypatch.delitem(sys.modules, "roboplan.simple_ik", raising=False) + monkeypatch.delitem(sys.modules, "roboplan.optimal_ik", raising=False) if include_simple_ik: simple_ik = ModuleType("roboplan.simple_ik") simple_ik.SimpleIkOptions = FakeSimpleIkOptions # type: ignore[attr-defined] simple_ik.SimpleIk = FakeSimpleIk # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "roboplan.simple_ik", simple_ik) + if include_optimal_ik: + optimal_ik = ModuleType("roboplan.optimal_ik") + optimal_ik.Oink = FakeOink # type: ignore[attr-defined] + optimal_ik.FrameTask = FakeFrameTask # type: ignore[attr-defined] + optimal_ik.FrameTaskOptions = FakeFrameTaskOptions # type: ignore[attr-defined] + optimal_ik.PositionLimit = FakePositionLimit # type: ignore[attr-defined] + optimal_ik.VelocityLimit = FakeVelocityLimit # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "roboplan.optimal_ik", optimal_ik) @pytest.fixture @@ -866,13 +1024,9 @@ def test_cartesian_free_requires_optional_simple_ik( result = world.plan_cartesian_path( world, - CartesianPlanningRequest( - selection=selection, - group_id="arm/manipulator", - start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), - target=PoseStamped(frame_id="world", position=Vector3(0.2, 0.0, 0.0)), # type: ignore[call-arg] - target_mode="absolute", - ), + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.2, 0.0, 0.0))}, # type: ignore[call-arg] ) assert result.status == PlanningStatus.UNSUPPORTED @@ -897,23 +1051,21 @@ def test_cartesian_free_absolute_uses_simple_ik_then_rrt( result = world.plan_cartesian_path( world, - CartesianPlanningRequest( - selection=selection, - group_id="arm/manipulator", - start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.1]), - target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] - target_mode="absolute", - timeout=2.0, - ), + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.1]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0))}, # type: ignore[call-arg] + timeout=2.0, ) assert result.status == PlanningStatus.SUCCESS assert [state.name for state in result.path] == [["arm/joint1", "arm/joint2"]] * 3 assert result.path[-1].position == [0.2, 0.2] assert FakeSimpleIk.last_options is not None + assert FakeSimpleIk.last_options.group_name == "manipulator" assert FakeSimpleIk.last_options.max_time == pytest.approx(2.0) assert FakeSimpleIk.last_goal is not None assert FakeSimpleIk.last_goal.frame_name == "tcp" + assert FakeSimpleIk.last_goal.tip_frame == "tcp" assert FakeRRT.last_start is not None np.testing.assert_allclose(FakeRRT.last_start.positions, [0.1, 0.1]) np.testing.assert_allclose(world._scene.current_positions, [0.1, 0.1]) @@ -938,15 +1090,11 @@ def test_cartesian_free_relative_delta_uses_world_axes( world.finalize() selection = _default_selection(world, robot_config) - result = world.plan_cartesian_path( + result = world.plan_relative_cartesian_path( world, - CartesianPlanningRequest( - selection=selection, - group_id="arm/manipulator", - start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.2]), - target=CartesianDelta(translation=(0.2, 0.0, 0.0), frame_id="world"), - target_mode="relative", - ), + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.2]), + {"arm/manipulator": CartesianDelta(translation=(0.2, 0.0, 0.0), frame_id="world")}, ) assert result.status == PlanningStatus.SUCCESS @@ -981,15 +1129,11 @@ def test_cartesian_free_relative_delta_composes_world_rotation( world.finalize() selection = _default_selection(world, robot_config) - result = world.plan_cartesian_path( + result = world.plan_relative_cartesian_path( world, - CartesianPlanningRequest( - selection=selection, - group_id="arm/manipulator", - start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.2]), - target=CartesianDelta(rotation_rpy=(0.0, 0.0, np.pi / 2.0), frame_id="world"), - target_mode="relative", - ), + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.2]), + {"arm/manipulator": CartesianDelta(rotation_rpy=(0.0, 0.0, np.pi / 2.0), frame_id="world")}, ) assert result.status == PlanningStatus.NO_SOLUTION @@ -1007,13 +1151,9 @@ def test_cartesian_free_malformed_start_returns_invalid_start( result = world.plan_cartesian_path( world, - CartesianPlanningRequest( - selection=selection, - group_id="arm/manipulator", - start=JointState(name=["arm/joint1"], position=[0.0]), - target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] - target_mode="absolute", - ), + selection, + JointState(name=["arm/joint1"], position=[0.0]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0))}, # type: ignore[call-arg] ) assert result.status == PlanningStatus.INVALID_START @@ -1046,20 +1186,16 @@ def solveIk( result = world.plan_cartesian_path( world, - CartesianPlanningRequest( - selection=selection, - group_id="arm/manipulator", - start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.1]), - target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] - target_mode="absolute", - ), + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.1]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0))}, # type: ignore[call-arg] ) assert result.status == PlanningStatus.NO_SOLUTION assert "final TCP pose missed target" in result.message -def test_cartesian_free_rejects_coupled_selection_for_v1( +def test_cartesian_free_requires_targets_and_auxiliaries_to_cover_selection( fake_roboplan: None, robot_config: RobotModelConfig ) -> None: world, _robot_id = _make_world(fake_roboplan, robot_config) @@ -1074,24 +1210,72 @@ def test_cartesian_free_rejects_coupled_selection_for_v1( result = world.plan_cartesian_path( world, - CartesianPlanningRequest( - selection=selection, - group_id="arm/manipulator", - start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), - target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] - target_mode="absolute", - ), + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0))}, # type: ignore[call-arg] ) - assert result.status == PlanningStatus.UNSUPPORTED - assert "exactly one selected TCP group" in result.message + assert result.status == PlanningStatus.INVALID_GOAL + assert "exactly cover selection.group_ids" in result.message -def test_cartesian_linear_mode_is_explicitly_unsupported_without_free_fallback( +def test_cartesian_free_rejects_target_auxiliary_overlap( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0))}, # type: ignore[call-arg] + auxiliary_groups=("arm/manipulator",), + ) + + assert result.status == PlanningStatus.INVALID_GOAL + assert "overlap auxiliary groups" in result.message + + +def test_cartesian_free_accepts_explicit_auxiliary_group_coverage( monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig ) -> None: _install_fake_roboplan(monkeypatch, include_simple_ik=True) module = _import_roboplan_world(None) + second_model = Path(robot_config.model_path).with_name("robot2.urdf") + second_model.write_text(Path(robot_config.model_path).read_text()) + right_config = robot_config.model_copy(update={"name": "right_arm", "model_path": second_model}) + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.add_robot(right_config) + world.finalize() + selection = world._planning_groups.select(["arm/manipulator", "right_arm/manipulator"]) + + result = world.plan_cartesian_path( + world, + selection, + JointState( + name=["arm/joint1", "arm/joint2", "right_arm/joint1", "right_arm/joint2"], + position=[0.0, 0.0, 0.0, 0.0], + ), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.0, 0.0, 0.0))}, # type: ignore[call-arg] + auxiliary_groups=("right_arm/manipulator",), + ) + + assert result.status == PlanningStatus.SUCCESS + assert FakeRRT.last_options is not None + assert ( + FakeRRT.last_options.group_name + == "_dimos_composite__arm_manipulator__right_arm_manipulator" + ) + + +def test_cartesian_linear_mode_supports_single_absolute_target( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_optimal_ik=True) + module = _import_roboplan_world(None) FakeRRT.last_start = None FakeSimpleIk.last_goal = None world = module.RoboPlanWorld() @@ -1101,48 +1285,213 @@ def test_cartesian_linear_mode_is_explicitly_unsupported_without_free_fallback( result = world.plan_cartesian_path( world, - CartesianPlanningRequest( - selection=selection, - group_id="arm/manipulator", - start=JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), - target=PoseStamped(frame_id="world", position=Vector3(0.4, 0.0, 0.0)), # type: ignore[call-arg] - target_mode="absolute", - path_mode="linear", + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.04, 0.0, 0.0))}, # type: ignore[call-arg] + path_mode="linear", + ) + + assert result.status == PlanningStatus.SUCCESS + assert result.path[-1].position == [0.02, 0.02] + assert FakeRRT.last_start is None + assert FakeSimpleIk.last_goal is None + assert FakeOink.last_group_name == "manipulator" + assert [goal.tform[0, 3] for goal in FakeFrameTask.targets] == pytest.approx( + [0.01, 0.02, 0.03, 0.04] + ) + assert {goal.base_frame for goal in FakeFrameTask.targets} == {""} + assert {goal.tip_frame for goal in FakeFrameTask.targets} == {"tcp"} + + +def test_cartesian_linear_mode_sets_selected_group_state_by_group_name( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_optimal_ik=True) + monkeypatch.setattr(FakeScene, "fail_unnamed_set_after_first", True) + module = _import_roboplan_world(None) + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.04, 0.0, 0.0))}, # type: ignore[call-arg] + path_mode="linear", + ) + + assert result.status == PlanningStatus.SUCCESS + assert any(group_name == "manipulator" for group_name, _q in FakeScene.set_joint_position_calls) + + +def test_cartesian_linear_mode_does_not_set_group_q_as_full_scene_state( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_optimal_ik=True) + monkeypatch.setattr(FakeScene, "reject_unnamed_set_position_size", 14) + monkeypatch.setattr(FakeScene, "reject_empty_group_to_full_size", 14) + module = _import_roboplan_world(None) + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.04, 0.0, 0.0))}, # type: ignore[call-arg] + path_mode="linear", + ) + + assert result.status == PlanningStatus.SUCCESS + unnamed_calls = [ + q for group_name, q in FakeScene.set_joint_position_calls if group_name is None + ] + assert unnamed_calls == [] + + +def test_cartesian_linear_mode_expands_group_state_when_group_setter_unavailable( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_optimal_ik=True) + monkeypatch.setattr(FakeScene, "reject_group_set_joint_positions", True) + monkeypatch.setattr(FakeScene, "group_to_full_size", 14) + monkeypatch.setattr(FakeScene, "reject_unnamed_set_position_size", 14) + module = _import_roboplan_world(None) + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_cartesian_path( + world, + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), + {"arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.04, 0.0, 0.0))}, # type: ignore[call-arg] + path_mode="linear", + ) + + assert result.status == PlanningStatus.SUCCESS + unnamed_calls = [ + q for group_name, q in FakeScene.set_joint_position_calls if group_name is None + ] + assert unnamed_calls + assert all(len(q) == 14 for q in unnamed_calls) + + +def test_cartesian_linear_mode_supports_single_relative_target( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_optimal_ik=True) + module = _import_roboplan_world(None) + FakeRRT.last_start = None + FakeSimpleIk.last_goal = None + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.finalize() + selection = _default_selection(world, robot_config) + + result = world.plan_relative_cartesian_path( + world, + selection, + JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.1]), + {"arm/manipulator": CartesianDelta(translation=(0.04, 0.0, 0.0))}, + path_mode="linear", + ) + + assert result.status == PlanningStatus.SUCCESS + assert result.path[-1].position == pytest.approx([0.12, 0.12]) + assert FakeRRT.last_start is None + assert FakeSimpleIk.last_goal is None + goal_xs = [goal.tform[0, 3] for goal in FakeFrameTask.targets] + assert goal_xs[0] > 0.2 + assert goal_xs == sorted(goal_xs) + assert goal_xs[-1] == pytest.approx(0.24) + + +def test_cartesian_linear_mode_rejects_multi_target_without_free_fallback( + monkeypatch: pytest.MonkeyPatch, robot_config: RobotModelConfig +) -> None: + _install_fake_roboplan(monkeypatch, include_optimal_ik=True) + module = _import_roboplan_world(None) + FakeRRT.last_start = None + FakeSimpleIk.last_goal = None + second_model = Path(robot_config.model_path).with_name("robot2.urdf") + second_model.write_text(Path(robot_config.model_path).read_text()) + right_config = robot_config.model_copy(update={"name": "right_arm", "model_path": second_model}) + world = module.RoboPlanWorld() + world.add_robot(robot_config) + world.add_robot(right_config) + world.finalize() + selection = world._planning_groups.select(["arm/manipulator", "right_arm/manipulator"]) + + result = world.plan_cartesian_path( + world, + selection, + JointState( + name=["arm/joint1", "arm/joint2", "right_arm/joint1", "right_arm/joint2"], + position=[0.0, 0.0, 0.0, 0.0], ), + { + "arm/manipulator": PoseStamped(frame_id="world", position=Vector3(0.04, 0.0, 0.0)), # type: ignore[call-arg] + "right_arm/manipulator": PoseStamped( + frame_id="world", position=Vector3(0.04, 0.0, 0.0) + ), # type: ignore[call-arg] + }, + path_mode="linear", ) assert result.status == PlanningStatus.UNSUPPORTED - assert "Linear Cartesian planning" in result.message + assert "at most one target group" in result.message assert FakeSimpleIk.last_goal is None assert FakeRRT.last_start is None @pytest.mark.parametrize( - ("request_update", "expected_status", "expected_message"), + ("mode", "target", "kwargs", "expected_status", "expected_message"), [ - ({"target_mode": "nonsense"}, PlanningStatus.INVALID_GOAL, "target_mode"), - ({"path_mode": "nonsense"}, PlanningStatus.INVALID_GOAL, "path_mode"), - ({"reference_frame": "tool"}, PlanningStatus.UNSUPPORTED, "reference_frame"), - ({"max_translation_step": 0.0}, PlanningStatus.INVALID_GOAL, "max_translation_step"), - ({"max_rotation_step": 0.0}, PlanningStatus.INVALID_GOAL, "max_rotation_step"), - ({"timeout": 0.0}, PlanningStatus.INVALID_GOAL, "timeout"), ( - {"target": CartesianDelta(), "target_mode": "absolute"}, + "absolute", + PoseStamped(frame_id="world"), + {"path_mode": "nonsense"}, + PlanningStatus.INVALID_GOAL, + "path_mode", + ), + ( + "absolute", + PoseStamped(frame_id="world"), + {"timeout": 0.0}, + PlanningStatus.INVALID_GOAL, + "timeout", + ), + ( + "absolute", + CartesianDelta(), + {}, PlanningStatus.INVALID_GOAL, "PoseStamped", ), ( - {"target": PoseStamped(frame_id="map"), "target_mode": "absolute"}, + "absolute", + PoseStamped(frame_id="map"), + {}, PlanningStatus.UNSUPPORTED, "world-frame poses", ), ( - {"target": PoseStamped(frame_id="world"), "target_mode": "relative"}, + "relative", + PoseStamped(frame_id="world"), + {}, PlanningStatus.INVALID_GOAL, "CartesianDelta", ), ( - {"target": CartesianDelta(frame_id="tool"), "target_mode": "relative"}, + "relative", + CartesianDelta(frame_id="tool"), + {}, PlanningStatus.UNSUPPORTED, "world-frame deltas", ), @@ -1151,23 +1500,32 @@ def test_cartesian_linear_mode_is_explicitly_unsupported_without_free_fallback( def test_cartesian_request_validation_branches( fake_roboplan: None, robot_config: RobotModelConfig, - request_update: dict[str, object], + mode: str, + target: object, + kwargs: dict[str, object], expected_status: PlanningStatus, expected_message: str, ) -> None: world, _robot_id = _make_world(fake_roboplan, robot_config) world.finalize() selection = _default_selection(world, robot_config) - request_kwargs = { - "selection": selection, - "group_id": "arm/manipulator", - "start": JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]), - "target": PoseStamped(frame_id="world", position=Vector3(0.2, 0.0, 0.0)), - "target_mode": "absolute", - } - request_kwargs.update(request_update) - - result = world.plan_cartesian_path(world, CartesianPlanningRequest(**request_kwargs)) + start = JointState(name=["arm/joint1", "arm/joint2"], position=[0.0, 0.0]) + if mode == "absolute": + result = world.plan_cartesian_path( + world, + selection, + start, + {"arm/manipulator": target}, # type: ignore[dict-item] + **kwargs, # type: ignore[arg-type] + ) + else: + result = world.plan_relative_cartesian_path( + world, + selection, + start, + {"arm/manipulator": target}, # type: ignore[dict-item] + **kwargs, # type: ignore[arg-type] + ) assert result.status == expected_status assert expected_message in result.message diff --git a/dimos/manipulation/test_roboplan_world_integration.py b/dimos/manipulation/test_roboplan_world_integration.py new file mode 100644 index 0000000000..b7d2519dbc --- /dev/null +++ b/dimos/manipulation/test_roboplan_world_integration.py @@ -0,0 +1,138 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Self-hosted integration tests for real RoboPlan bindings.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dimos.manipulation.planning.spec.enums import PlanningStatus +from dimos.manipulation.planning.utils.kinematics_utils import compute_pose_error +from dimos.manipulation.planning.world.roboplan_world import RoboPlanWorld +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.robot.manipulators.xarm.config import make_xarm6_model_config +from dimos.utils.transform_utils import matrix_to_pose, pose_to_matrix + +pytestmark = pytest.mark.self_hosted + +_ROBOPLAN_CORE = pytest.importorskip("roboplan.core") +_ROBOPLAN_OPTIMAL_IK = pytest.importorskip("roboplan.optimal_ik") + + +def test_real_roboplan_linear_tcp_oink_plans_xarm6_mimic_model() -> None: + """Exercise real Oink linear TCP planning with a mimic-joint xArm6 model.""" + config = make_xarm6_model_config(name="left_arm") + if not Path(config.model_path).exists(): + pytest.skip(f"xArm model is unavailable: {config.model_path}") + + world = RoboPlanWorld() + world.add_robot(config) + world.finalize() + group_id = world._planning_groups.primary_pose_group_id_for_robot("left_arm") + assert group_id == "left_arm/manipulator" + selection = world._planning_groups.select((group_id,)) + start = JointState( + name=list(selection.joint_names), position=[0.0] * len(selection.joint_names) + ) + + with world.scratch_context() as ctx: + world._set_selection_state(ctx, selection, start) + start_pose = world.get_group_ee_pose(ctx, group_id) + + target_matrix = pose_to_matrix(start_pose) + target_matrix[0, 3] += 0.005 + target_pose = matrix_to_pose(target_matrix) + target = PoseStamped( + frame_id="world", + position=target_pose.position, + orientation=target_pose.orientation, + ) + + result = world.plan_cartesian_path( + world, + selection, + start, + {group_id: target}, + path_mode="linear", + timeout=5.0, + ) + + assert result.status == PlanningStatus.SUCCESS, result.message + assert len(result.path) >= 2 + assert result.path[-1].name == list(selection.joint_names) + with world.scratch_context() as ctx: + world._set_selection_state(ctx, selection, result.path[-1]) + final_pose = world.get_group_ee_pose(ctx, group_id) + position_error, orientation_error = compute_pose_error( + pose_to_matrix(final_pose), pose_to_matrix(target) + ) + assert position_error <= 1e-3 + assert orientation_error <= 1e-2 + + +def test_real_roboplan_linear_tcp_oink_plans_dual_xarm6_right_arm_plus_x() -> None: + """Exercise world-frame Oink targets in a dual-arm composite scene.""" + left_config = make_xarm6_model_config(name="left_arm", y_offset=0.3) + right_config = make_xarm6_model_config(name="right_arm", y_offset=-0.3) + if not Path(left_config.model_path).exists(): + pytest.skip(f"xArm model is unavailable: {left_config.model_path}") + + world = RoboPlanWorld() + world.add_robot(left_config) + world.add_robot(right_config) + world.finalize() + group_id = world._planning_groups.primary_pose_group_id_for_robot("right_arm") + assert group_id == "right_arm/manipulator" + selection = world._planning_groups.select((group_id,)) + start = JointState( + name=list(selection.joint_names), position=[0.0] * len(selection.joint_names) + ) + + with world.scratch_context() as ctx: + world._set_selection_state(ctx, selection, start) + start_pose = world.get_group_ee_pose(ctx, group_id) + + target_matrix = pose_to_matrix(start_pose) + target_matrix[0, 3] += 0.005 + target_pose = matrix_to_pose(target_matrix) + target = PoseStamped( + frame_id="world", + position=target_pose.position, + orientation=target_pose.orientation, + ) + + result = world.plan_cartesian_path( + world, + selection, + start, + {group_id: target}, + path_mode="linear", + timeout=5.0, + ) + + assert result.status == PlanningStatus.SUCCESS, result.message + assert len(result.path) >= 2 + assert result.path[-1].name == list(selection.joint_names) + with world.scratch_context() as ctx: + world._set_selection_state(ctx, selection, result.path[-1]) + final_pose = world.get_group_ee_pose(ctx, group_id) + position_error, orientation_error = compute_pose_error( + pose_to_matrix(final_pose), pose_to_matrix(target) + ) + assert position_error <= 1e-3 + assert orientation_error <= 1e-2 diff --git a/dimos/manipulation/visualization/viser/gui.py b/dimos/manipulation/visualization/viser/gui.py index bd6376c61c..89d320661d 100644 --- a/dimos/manipulation/visualization/viser/gui.py +++ b/dimos/manipulation/visualization/viser/gui.py @@ -46,6 +46,7 @@ PanelPlanState, PanelRuntime, PanelState, + PlanRecipe, PlanStatus, TargetEvaluationRequest, TargetEvaluationWorker, @@ -278,6 +279,11 @@ def _build_panel_controls(self, gui: GuiApi) -> None: f"Feasibility: `{self.state.feasibility.status.value}`" ) self._handles["actions_heading"] = gui.add_markdown("### Actions") + linear_tcp_checkbox = gui.add_checkbox("Linear TCP path", initial_value=False) + linear_tcp_checkbox.on_update( + lambda event: self._set_next_plan_linear_tcp(bool(event.target.value)) + ) + self._handles["linear_tcp_path"] = linear_tcp_checkbox plan_button = gui.add_button("Plan", disabled=True, color=PRIMARY_ACTION_COLOR) plan_button.on_click(lambda _: self._submit_plan()) self._handles["plan"] = plan_button @@ -964,6 +970,8 @@ def _update_status_text(self) -> None: "### Status", f"**State:** {status_label}", f"Target: `{self.state.target_status.value}` · Plan: `{self.state.plan_state.status.value}`", + f"Plan recipe: `{self.state.plan_state.recipe.value if self.state.plan_state.recipe else 'none'}`", + f"Next Plan: `{'linear_tcp' if self.state.next_plan_linear_tcp else 'standard'}`", ] if self.state.selected_robot is not None: status.append(f"State stale: `{self._is_state_stale(self.state.selected_robot)}`") @@ -993,6 +1001,10 @@ def _update_control_state(self) -> None: self._set_visible("cancel", can_cancel) self._update_target_visual_state() + def _set_next_plan_linear_tcp(self, enabled: bool) -> None: + self.state.next_plan_linear_tcp = enabled + self.refresh() + def _update_target_visual_state(self) -> None: if self.scene is None: return @@ -1028,20 +1040,42 @@ def operation() -> None: self.state.plan_state.status = PlanStatus.FAILED self._finish_operation("reset=False", clear_error=False, operation_id=operation_id) return - targets = self._target_set_from_sliders() - if targets is None: - self.state.plan_state.status = PlanStatus.FAILED - self._finish_operation( - "plan_to_joints=False", clear_error=False, operation_id=operation_id - ) - return - ok = self.manipulation_module.plan_to_joint_targets( - cast("Mapping[PlanningGroupID | PlanningGroup, JointState]", targets) + recipe = ( + PlanRecipe.LINEAR_TCP if self.state.next_plan_linear_tcp else PlanRecipe.STANDARD ) + finish_label = "plan_to_joints" + if recipe is PlanRecipe.LINEAR_TCP: + pose_targets = self._active_pose_targets() + if not pose_targets: + self.state.plan_state.status = PlanStatus.FAILED + self._set_recoverable_error("Linear TCP path requires an active pose target") + self._finish_operation( + "plan_linear_to_pose_targets=False", + clear_error=False, + operation_id=operation_id, + ) + return + finish_label = "plan_linear_to_pose_targets" + ok = self.manipulation_module.plan_linear_to_pose_targets( + cast("Mapping[PlanningGroupID | PlanningGroup, Pose]", pose_targets), + auxiliary_groups=self.state.auxiliary_group_ids, + ) + else: + targets = self._target_set_from_sliders() + if targets is None: + self.state.plan_state.status = PlanStatus.FAILED + self._finish_operation( + "plan_to_joints=False", clear_error=False, operation_id=operation_id + ) + return + ok = self.manipulation_module.plan_to_joint_targets( + cast("Mapping[PlanningGroupID | PlanningGroup, JointState]", targets) + ) if not self._operation_is_current(operation_id): return if ok: self.state.plan_state.status = PlanStatus.FRESH + self.state.plan_state.recipe = recipe self.state.plan_state.group_ids = self.state.selected_group_ids self.state.plan_state.robot = self.state.selected_robot self.state.plan_state.target_joints = list( @@ -1052,7 +1086,7 @@ def operation() -> None: self.state.plan_state.planned_path = None else: self.state.plan_state.status = PlanStatus.FAILED - self._finish_operation(f"plan_to_joints={ok}", operation_id=operation_id) + self._finish_operation(f"{finish_label}={ok}", operation_id=operation_id) self._operation_worker.submit( operation, on_error=lambda message: self._set_operation_error(message, operation_id) diff --git a/dimos/manipulation/visualization/viser/state.py b/dimos/manipulation/visualization/viser/state.py index e793c9bf9f..f36377b2d3 100644 --- a/dimos/manipulation/visualization/viser/state.py +++ b/dimos/manipulation/visualization/viser/state.py @@ -70,6 +70,11 @@ class PlanStatus(str, Enum): FAILED = "failed" +class PlanRecipe(str, Enum): + STANDARD = "standard" + LINEAR_TCP = "linear_tcp" + + class ActionStatus(str, Enum): IDLE = "idle" RUNNING = "running" @@ -93,6 +98,7 @@ class FeasibilityState: @dataclass class PanelPlanState: status: PlanStatus = PlanStatus.NONE + recipe: PlanRecipe | None = None group_ids: tuple[PlanningGroupID, ...] = () robot: str | None = None target_pose: Pose | None = None @@ -121,6 +127,7 @@ class PanelState: current_ee_pose: Pose | None = None cartesian_target: Pose | None = None joint_target: list[float] | None = None + next_plan_linear_tcp: bool = False feasibility: FeasibilityState = field(default_factory=FeasibilityState) latest_sequence_id: int = 0 plan_state: PanelPlanState = field(default_factory=PanelPlanState) diff --git a/dimos/manipulation/visualization/viser/test_operation_worker.py b/dimos/manipulation/visualization/viser/test_operation_worker.py index 53184c91eb..87486e029c 100644 --- a/dimos/manipulation/visualization/viser/test_operation_worker.py +++ b/dimos/manipulation/visualization/viser/test_operation_worker.py @@ -34,6 +34,7 @@ TargetEvaluationWorker, TargetStatus, ) +from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.sensor_msgs.JointState import JointState @@ -157,6 +158,12 @@ def robot_items(self) -> list[tuple[str, str, object]]: def plan_to_joint_targets(self, joint_targets: dict[str, JointState]) -> bool: return True + def plan_linear_to_pose_targets( + self, pose_targets: dict[str, Pose], auxiliary_groups: tuple[str, ...] = () + ) -> bool: + del pose_targets, auxiliary_groups + return True + def test_operation_worker_uses_per_operation_timeout() -> None: errors: list[str] = [] diff --git a/dimos/manipulation/visualization/viser/test_viser_visualization.py b/dimos/manipulation/visualization/viser/test_viser_visualization.py index ce963ce1dc..002592fed4 100644 --- a/dimos/manipulation/visualization/viser/test_viser_visualization.py +++ b/dimos/manipulation/visualization/viser/test_viser_visualization.py @@ -54,6 +54,7 @@ FeasibilityStatus, OperationWorker, PanelPlanState, + PlanRecipe, PlanStatus, TargetEvaluationRequest, TargetEvaluationWorker, @@ -534,6 +535,17 @@ def inverse_kinematics( ) def plan_to_joint_targets(self, _joint_targets: dict[str, JointState]) -> bool: + self.plan_to_joint_targets_calls = getattr(self, "plan_to_joint_targets_calls", 0) + 1 + return True + + def plan_linear_to_pose_targets( + self, + pose_targets: dict[str, Pose], + auxiliary_groups: Sequence[str] = (), + ) -> bool: + calls = list(getattr(self, "linear_pose_target_calls", [])) + calls.append((dict(pose_targets), tuple(auxiliary_groups))) + self.linear_pose_target_calls = calls return True def reset(self) -> bool: @@ -615,6 +627,7 @@ def test_gui_builds_controls_in_manipulation_panel_folder( assert "target_heading" in gui._handles assert "target_summary" in gui._handles assert "actions_heading" in gui._handles + assert "linear_tcp_path" in gui._handles assert "plan" in gui._handles assert "select_all_manipulators" not in gui._handles assert "clear_group_selection" not in gui._handles @@ -640,6 +653,9 @@ def test_gui_builds_controls_in_manipulation_panel_folder( plan_button = gui._handles["plan"] assert isinstance(plan_button, GuiButtonHandle) assert plan_button.color == PRIMARY_ACTION_COLOR + linear_tcp_checkbox = gui._handles["linear_tcp_path"] + assert isinstance(linear_tcp_checkbox, GuiCheckboxHandle) + assert linear_tcp_checkbox.label == "Linear TCP path" group_button = gui._handles[f"group:{DEFAULT_GROUP_ID}"] assert isinstance(group_button, GuiButtonHandle) assert group_button.label == "arm" @@ -651,6 +667,23 @@ def test_gui_builds_controls_in_manipulation_panel_folder( assert gui._operation_worker._timeout_seconds is None +def test_linear_tcp_checkbox_updates_next_plan_without_staling_current_plan( + make_panel: Callable[..., ViserPanelGui], +) -> None: + server = FakeGuiServer() + gui = make_panel(server, make_module_with_robot(), ViserVisualizationConfig()) + gui.state.plan_state = PanelPlanState(status=PlanStatus.FRESH, recipe=PlanRecipe.STANDARD) + checkbox = server.checkboxes["Linear TCP path"] + + checkbox.value = True + assert checkbox.update_callback is not None + checkbox.update_callback(SimpleNamespace(target=checkbox)) + + assert gui.state.next_plan_linear_tcp is True + assert gui.state.plan_state.status == PlanStatus.FRESH + assert gui.state.plan_state.recipe is PlanRecipe.STANDARD + + def test_gui_scene_grid_checkbox_toggles_reference_grid( make_panel: Callable[..., ViserPanelGui], ) -> None: @@ -1919,6 +1952,69 @@ def plan_target_set(_joint_targets: dict[str, JointState]) -> bool: assert gui.state.last_result == "plan_to_joints=True" +def test_gui_linear_tcp_plan_uses_active_pose_targets_and_records_recipe( + make_panel: Callable[..., ViserPanelGui], + monkeypatch: pytest.MonkeyPatch, +) -> None: + module_context = make_module_with_robot() + module = module_context[1] + gui = make_panel(FakeGuiServer(), module_context) + gui._operation_worker.stop() + monkeypatch.setattr( + gui, + "_operation_worker", + SimpleNamespace( + submit=lambda operation, **_kwargs: operation(), stop=lambda timeout=2.0: None + ), + ) + pose = Pose(position=Vector3(0.1, 0.2, 0.3), orientation=Quaternion()) + gui.state.next_plan_linear_tcp = True + gui.state.selected_group_ids = (DEFAULT_GROUP_ID,) + gui.state.auxiliary_group_ids = ("arm:auxiliary",) + gui.state.pose_targets = {DEFAULT_GROUP_ID: pose} + gui.state.cartesian_target = pose + gui.state.target_joints = JointState({"name": ["arm/j1"], "position": [1.0]}) + gui.state.target_status = TargetStatus.FEASIBLE + gui.state.manipulation_state = "IDLE" + + gui._submit_plan() + + assert getattr(module, "plan_to_joint_targets_calls", 0) == 0 + assert module.linear_pose_target_calls == [({DEFAULT_GROUP_ID: pose}, ("arm:auxiliary",))] + assert gui.state.plan_state.status == PlanStatus.FRESH + assert gui.state.plan_state.recipe is PlanRecipe.LINEAR_TCP + assert gui.state.last_result == "plan_linear_to_pose_targets=True" + + +def test_gui_linear_tcp_plan_requires_active_pose_target( + make_panel: Callable[..., ViserPanelGui], + monkeypatch: pytest.MonkeyPatch, +) -> None: + module_context = make_module_with_robot() + module = module_context[1] + gui = make_panel(FakeGuiServer(), module_context) + gui._operation_worker.stop() + monkeypatch.setattr( + gui, + "_operation_worker", + SimpleNamespace( + submit=lambda operation, **_kwargs: operation(), stop=lambda timeout=2.0: None + ), + ) + gui.state.next_plan_linear_tcp = True + gui.state.selected_group_ids = (DEFAULT_GROUP_ID,) + gui.state.target_joints = JointState({"name": ["arm/j1"], "position": [1.0]}) + gui.state.target_status = TargetStatus.FEASIBLE + gui.state.manipulation_state = "IDLE" + + gui._submit_plan() + + assert getattr(module, "linear_pose_target_calls", []) == [] + assert gui.state.plan_state.status == PlanStatus.FAILED + assert gui.state.error == "Linear TCP path requires an active pose target" + assert gui.state.last_result == "plan_linear_to_pose_targets=False" + + def test_operation_worker_coalesces_pending_requests() -> None: errors = [] calls = [] diff --git a/openspec/specs/manipulation-linear-tcp-planning/spec.md b/openspec/specs/manipulation-linear-tcp-planning/spec.md new file mode 100644 index 0000000000..0134d09b5e --- /dev/null +++ b/openspec/specs/manipulation-linear-tcp-planning/spec.md @@ -0,0 +1,81 @@ +## Purpose + +Define public manipulation APIs and Viser behavior for explicit linear TCP path planning from pose targets. + +## Requirements + +### Requirement: ManipulationModule exposes intentful linear and relative pose planning APIs +The system SHALL expose public `ManipulationModule` methods for explicit linear TCP path planning and relative pose-target planning while preserving the existing standard pose-target planning API. + +#### Scenario: Standard pose planning remains unchanged +- **WHEN** a caller invokes `plan_to_pose_targets(...)` +- **THEN** the system SHALL keep the existing behavior of solving IK for the absolute pose targets and planning a selected joint path +- **AND** the call SHALL NOT require planner-level Cartesian path support + +#### Scenario: Absolute linear pose planning uses planner linear Cartesian mode +- **WHEN** a caller invokes `plan_linear_to_pose_targets(pose_targets, auxiliary_groups=..., timeout=...)` +- **THEN** the system SHALL resolve the selected planning groups from `pose_targets` and `auxiliary_groups` +- **AND** it SHALL call the planner-level absolute Cartesian method with `path_mode="linear"` +- **AND** it SHALL store the resulting plan when planning succeeds + +#### Scenario: Relative free pose planning uses relative Cartesian planner mode +- **WHEN** a caller invokes `plan_relative_to_pose_targets(delta_targets, auxiliary_groups=..., timeout=...)` +- **THEN** the system SHALL resolve the selected planning groups from `delta_targets` and `auxiliary_groups` +- **AND** it SHALL call the planner-level relative Cartesian method with `path_mode="free"` +- **AND** it SHALL store the resulting plan when planning succeeds + +#### Scenario: Relative linear pose planning uses relative linear Cartesian planner mode +- **WHEN** a caller invokes `plan_linear_relative_to_pose_targets(delta_targets, auxiliary_groups=..., timeout=...)` +- **THEN** the system SHALL resolve the selected planning groups from `delta_targets` and `auxiliary_groups` +- **AND** it SHALL call the planner-level relative Cartesian method with `path_mode="linear"` +- **AND** it SHALL store the resulting plan when planning succeeds + +#### Scenario: Explicit public methods fail without fallback +- **WHEN** one of the explicit linear or relative public planning methods receives a non-success `PlanningResult` +- **THEN** the method SHALL return `False` +- **AND** it SHALL expose an explanatory module error +- **AND** it SHALL NOT silently fall back to IK plus joint-space planning + +### Requirement: Viser exposes linear TCP path planning as a next-plan option +The Viser manipulation panel SHALL provide a simple `Linear TCP path` option that affects the next Plan action without changing existing standard planning behavior. + +#### Scenario: Linear TCP checkbox unchecked uses standard planning +- **WHEN** the `Linear TCP path` checkbox is unchecked and the user clicks Plan +- **THEN** Viser SHALL keep the existing behavior of planning from IK-evaluated joint targets through `plan_to_joint_targets(...)` + +#### Scenario: Linear TCP checkbox checked uses absolute pose targets +- **WHEN** the `Linear TCP path` checkbox is checked and the user clicks Plan with feasible active pose targets +- **THEN** Viser SHALL call `plan_linear_to_pose_targets(...)` using the current active absolute pose targets +- **AND** it SHALL pass the current auxiliary planning group IDs + +#### Scenario: Linear TCP checkbox does not add relative UI +- **WHEN** the Viser panel renders phase-1 linear TCP planning controls +- **THEN** it SHALL NOT expose relative-motion controls +- **AND** it SHALL NOT expose a broader Cartesian free/linear planning selector + +#### Scenario: Missing pose target fails recoverably +- **WHEN** the `Linear TCP path` checkbox is checked and Plan is requested without active pose targets +- **THEN** Viser SHALL report a recoverable planning error +- **AND** it SHALL NOT attempt to plan from joint slider targets as a linear TCP path + +### Requirement: Viser preserves current plan recipe independently from next-plan settings +The Viser panel SHALL track the recipe used to create the current fresh plan separately from the checkbox setting used for future Plan actions. + +#### Scenario: Changing checkbox does not stale current plan +- **WHEN** a fresh plan exists +- **AND** the user toggles the `Linear TCP path` checkbox +- **THEN** the fresh plan SHALL remain fresh +- **AND** preview and execute eligibility SHALL continue to be based on the current plan state and normal freshness checks + +#### Scenario: Current plan records standard recipe +- **WHEN** a standard plan succeeds through `plan_to_joint_targets(...)` +- **THEN** the current plan state SHALL record the plan recipe as standard + +#### Scenario: Current plan records linear TCP recipe +- **WHEN** a linear TCP plan succeeds through `plan_linear_to_pose_targets(...)` +- **THEN** the current plan state SHALL record the plan recipe as linear TCP + +#### Scenario: Status distinguishes current and next plan recipe +- **WHEN** Viser displays plan status +- **THEN** it SHALL expose the recipe used by the current plan +- **AND** it SHALL not imply that toggling the next-plan checkbox changed the current plan recipe diff --git a/openspec/specs/roboplan-cartesian-planning/spec.md b/openspec/specs/roboplan-cartesian-planning/spec.md index ca9ef6e2fb..a535d5cd25 100644 --- a/openspec/specs/roboplan-cartesian-planning/spec.md +++ b/openspec/specs/roboplan-cartesian-planning/spec.md @@ -1,92 +1,145 @@ ## Purpose -Define planner-level Cartesian path planning for RoboPlan-backed planners, including absolute Cartesian goals, relative Cartesian deltas, and linear TCP path mode. +Define planner-level Cartesian path planning for RoboPlan-backed planners, including absolute Cartesian goals, relative Cartesian deltas, explicit auxiliary planning groups, and linear TCP path mode. ## Requirements +### Requirement: Cartesian planner validates target and auxiliary coverage +The system SHALL require Cartesian planner calls to partition the selected planning groups into targeted groups and explicit auxiliary planning groups. + +#### Scenario: Target and auxiliary groups exactly cover selection +- **WHEN** a Cartesian planner call provides a `PlanningGroupSelection`, target map, and auxiliary group list +- **THEN** the target group IDs and auxiliary group IDs SHALL be disjoint +- **AND** their union SHALL equal `set(selection.group_ids)` + +#### Scenario: Selected group is neither targeted nor auxiliary +- **WHEN** a selected planning group is absent from both the target map and `auxiliary_groups` +- **THEN** the planner SHALL return a non-success `PlanningResult` with an explanatory validation message + +#### Scenario: Target group is not selected +- **WHEN** a target map contains a planning group ID that is not in `selection.group_ids` +- **THEN** the planner SHALL return a non-success `PlanningResult` with an explanatory validation message + +#### Scenario: Auxiliary group has a target +- **WHEN** a planning group ID appears in both the target map and `auxiliary_groups` +- **THEN** the planner SHALL return a non-success `PlanningResult` with an explanatory validation message + ### Requirement: PlannerSpec exposes Cartesian path planning -`PlannerSpec` SHALL expose `plan_cartesian_path(world: WorldSpec, request: CartesianPlanningRequest) -> PlanningResult` for planner-level Cartesian TCP requests. +`PlannerSpec` SHALL expose direct Cartesian planning methods for absolute and relative task-space goals: `plan_cartesian_path(world: WorldSpec, selection: PlanningGroupSelection, start: JointState, pose_targets: Mapping[PlanningGroupID, PoseStamped], *, auxiliary_groups: Sequence[PlanningGroupID] = (), path_mode: CartesianPathMode = "free", timeout: float = 10.0) -> PlanningResult` and `plan_relative_cartesian_path(world: WorldSpec, selection: PlanningGroupSelection, start: JointState, delta_targets: Mapping[PlanningGroupID, CartesianDelta], *, auxiliary_groups: Sequence[PlanningGroupID] = (), path_mode: CartesianPathMode = "free", timeout: float = 10.0) -> PlanningResult`. -#### Scenario: Planner receives Cartesian request object -- **WHEN** a caller has a selected planning group, start joint state, and Cartesian target -- **THEN** the caller SHALL be able to invoke `plan_cartesian_path(...)` with one `CartesianPlanningRequest` +#### Scenario: Planner receives direct absolute Cartesian parameters +- **WHEN** a caller has a selected planning group set, selected start joint state, and one or more absolute Cartesian pose targets +- **THEN** the caller SHALL be able to invoke `plan_cartesian_path(...)` without constructing a request dataclass +- **AND** the planner SHALL return a `PlanningResult` + +#### Scenario: Planner receives direct relative Cartesian parameters +- **WHEN** a caller has a selected planning group set, selected start joint state, and one or more relative Cartesian delta targets +- **THEN** the caller SHALL be able to invoke `plan_relative_cartesian_path(...)` without constructing a request dataclass - **AND** the planner SHALL return a `PlanningResult` #### Scenario: Cartesian planning does not replace joint planning - **WHEN** a caller already has a joint-space goal - **THEN** the caller SHALL continue to use `plan_selected_joint_path(...)` -- **AND** `plan_cartesian_path(...)` SHALL be reserved for TCP-space targets or deltas - -### Requirement: CartesianPlanningRequest defines the public API -The system SHALL define `CartesianPlanningRequest` with fields `selection: PlanningGroupSelection`, `group_id: PlanningGroupID`, `start: JointState`, `target: PoseStamped | CartesianDelta`, `target_mode: Literal["absolute", "relative"]`, `path_mode: Literal["free", "linear"] = "free"`, `reference_frame: str = "world"`, `max_translation_step: float = 0.01`, `max_rotation_step: float = 0.05`, and `timeout: float = 10.0`. +- **AND** the existing joint-space planner method signatures SHALL remain unchanged -#### Scenario: Request names the planned joints and active TCP group -- **WHEN** a Cartesian request is constructed -- **THEN** `selection` SHALL define the selected global joints that are planned and returned -- **AND** `group_id` SHALL identify the planning group's TCP/target frame that receives the Cartesian target - -#### Scenario: Request controls path mode -- **WHEN** a Cartesian request sets `path_mode` to `"free"` or `"linear"` -- **THEN** the planner SHALL interpret that value as the requested Cartesian path semantics +#### Scenario: Selection defines planned joint order +- **WHEN** a Cartesian planner call provides a `PlanningGroupSelection` +- **THEN** `selection` SHALL define the planned groups, selected global joints, and returned joint ordering +- **AND** target map insertion order SHALL NOT define selected joint ordering ### Requirement: CartesianDelta defines relative Cartesian targets The system SHALL define `CartesianDelta` with fields `translation: tuple[float, float, float] = (0.0, 0.0, 0.0)`, `rotation_rpy: tuple[float, float, float] = (0.0, 0.0, 0.0)`, and `frame_id: str = "world"`. #### Scenario: Relative target uses delta model -- **WHEN** `CartesianPlanningRequest.target_mode` is `"relative"` -- **THEN** `target` SHALL be a `CartesianDelta` +- **WHEN** a caller invokes `plan_relative_cartesian_path(...)` +- **THEN** each value in `delta_targets` SHALL be a `CartesianDelta` - **AND** translation SHALL be interpreted in meters - **AND** rotation SHALL be interpreted as roll, pitch, yaw in radians #### Scenario: Absolute target uses stamped pose -- **WHEN** `CartesianPlanningRequest.target_mode` is `"absolute"` -- **THEN** `target` SHALL be a `PoseStamped` +- **WHEN** a caller invokes `plan_cartesian_path(...)` +- **THEN** each value in `pose_targets` SHALL be a `PoseStamped` + +#### Scenario: Relative delta frame is unsupported outside world +- **WHEN** a relative Cartesian planner call includes a `CartesianDelta` whose `frame_id` is not `"world"` +- **THEN** the planner SHALL return `PlanningStatus.UNSUPPORTED` or another non-success `PlanningResult` explaining that non-world relative frames are unsupported + +#### Scenario: Absolute pose frame is unsupported outside world +- **WHEN** an absolute Cartesian planner call includes a `PoseStamped` whose `frame_id` is not `""` or `"world"` +- **THEN** the planner SHALL return `PlanningStatus.UNSUPPORTED` or another non-success `PlanningResult` explaining that non-world absolute frames are unsupported ### Requirement: Free Cartesian mode plans to the target pose -For `path_mode="free"`, the planner SHALL plan a collision-free selected-joint path whose final TCP pose satisfies the Cartesian target without requiring straight-line TCP motion between start and target. +For `path_mode="free"`, the planner SHALL plan a collision-free selected-joint path whose final targeted TCP poses satisfy the Cartesian targets without requiring straight-line TCP motion between start and target. -#### Scenario: Absolute free Cartesian target +#### Scenario: Absolute free Cartesian targets - **WHEN** a RoboPlan-backed planner receives an absolute Cartesian request with `path_mode="free"` -- **THEN** it SHALL plan to the requested final TCP pose if supported and feasible +- **THEN** it SHALL plan to all requested final TCP poses if supported and feasible - **AND** it SHALL return selected global-joint waypoints in `PlanningResult.path` -#### Scenario: Relative free Cartesian target +#### Scenario: Relative free Cartesian targets - **WHEN** a RoboPlan-backed planner receives a relative Cartesian request with `path_mode="free"` -- **THEN** it SHALL compute the start TCP pose from `request.start` and `request.group_id` -- **AND** it SHALL apply `CartesianDelta` to derive the final target pose before planning +- **THEN** it SHALL compute each targeted start TCP pose from `start` +- **AND** it SHALL apply each `CartesianDelta` to derive final absolute target poses before planning + +#### Scenario: Free mode has no path-shape guarantee +- **WHEN** a planner returns success for `path_mode="free"` +- **THEN** the final targeted TCP poses SHALL satisfy the requested Cartesian targets +- **AND** intermediate waypoints SHALL NOT be required to preserve Cartesian target constraints or straight-line TCP motion ### Requirement: Linear Cartesian mode preserves straight-line TCP intent -For `path_mode="linear"`, the planner SHALL require or request straight-line TCP motion from the start TCP pose to the target TCP pose according to the request interpolation limits. +For `path_mode="linear"`, the planner SHALL require or request straight-line TCP motion from the start TCP pose to the target TCP pose. In v1, RoboPlanWorld SHALL support linear mode for exactly one targeted TCP group and SHALL reject multi-target linear requests. + +RoboPlanWorld SHALL implement single-target linear mode through RoboPlan Oink task-space IK over sampled straight-line TCP waypoints. It SHALL NOT use RoboPlan SimpleIk for linear mode, and it SHALL NOT fall back to free-space joint planning when Oink cannot satisfy the sampled path. + +#### Scenario: Single-target linear mode is supported +- **WHEN** a RoboPlan-backed Cartesian planner call has `path_mode="linear"` and exactly one targeted TCP group +- **THEN** the planner SHALL attempt a straight-line Cartesian TCP path for that target +- **AND** RoboPlanWorld SHALL use Oink task-space IK to solve the sampled Cartesian waypoints +- **AND** a successful result SHALL contain selected global-joint waypoints whose TCP samples follow the requested Cartesian segment #### Scenario: Linear mode succeeds only for linear TCP path - **WHEN** a RoboPlan-backed planner returns success for `path_mode="linear"` -- **THEN** the returned joint waypoints SHALL represent a TCP path that honors the requested straight-line Cartesian segment within the request interpolation limits +- **THEN** the returned joint waypoints SHALL represent a TCP path that honors the requested straight-line Cartesian segment #### Scenario: Linear mode is not silently downgraded - **WHEN** the planner cannot support or verify linear TCP path semantics - **THEN** it SHALL return `PlanningStatus.UNSUPPORTED` or a non-success planning status - **AND** it SHALL NOT return a successful free-space joint path as if it satisfied linear mode +#### Scenario: Multi-target linear mode is unsupported in v1 +- **WHEN** a Cartesian planner call has `path_mode="linear"` and more than one targeted TCP group +- **THEN** the planner SHALL return `PlanningStatus.UNSUPPORTED` or another non-success status +- **AND** it SHALL NOT select an implicit primary target group + ### Requirement: Unsupported planners fail explicitly -Planners that do not support Cartesian planning SHALL return `PlanningStatus.UNSUPPORTED` from `plan_cartesian_path(...)` with an explanatory message. +Planners that do not support Cartesian planning SHALL return `PlanningStatus.UNSUPPORTED` from both `plan_cartesian_path(...)` and `plan_relative_cartesian_path(...)` with explanatory messages. -#### Scenario: Generic joint planner receives Cartesian request +#### Scenario: Generic joint planner receives absolute Cartesian request - **WHEN** a non-Cartesian planner receives `plan_cartesian_path(...)` - **THEN** it SHALL return `PlanningStatus.UNSUPPORTED` - **AND** it SHALL NOT perform an implicit IK-to-joint-plan fallback +#### Scenario: Generic joint planner receives relative Cartesian request +- **WHEN** a non-Cartesian planner receives `plan_relative_cartesian_path(...)` +- **THEN** it SHALL return `PlanningStatus.UNSUPPORTED` +- **AND** it SHALL NOT perform an implicit IK-to-joint-plan fallback + ### Requirement: RoboPlanWorld implements Cartesian planning as PlannerSpec -`RoboPlanWorld` SHALL implement `plan_cartesian_path(...)` because it is the RoboPlan-backed object that implements both `WorldSpec` and `PlannerSpec`. +`RoboPlanWorld` SHALL implement the revised absolute and relative Cartesian planning methods because it is the RoboPlan-backed object that implements both `WorldSpec` and `PlannerSpec`. #### Scenario: RoboPlanWorld returns public joint-state path - **WHEN** RoboPlan-backed Cartesian planning succeeds - **THEN** `RoboPlanWorld` SHALL convert the native path into `PlanningResult.path` using public global joint names in the caller's selected order #### Scenario: RoboPlan native details stay internal -- **WHEN** callers use `plan_cartesian_path(...)` +- **WHEN** callers use `plan_cartesian_path(...)` or `plan_relative_cartesian_path(...)` - **THEN** callers SHALL NOT need to import or pass `roboplan.*` objects - **AND** RoboPlan-specific options SHALL remain inside the RoboPlan adapter/configuration #### Scenario: RoboPlan request validation fails clearly -- **WHEN** `target_mode`, `target`, `group_id`, selected joints, or interpolation limits are invalid or unsupported +- **WHEN** target maps, auxiliary groups, selected joints, target frames, start state, or path mode are invalid or unsupported - **THEN** `RoboPlanWorld` SHALL return an appropriate non-success `PlanningResult` with an explanatory message + +#### Scenario: RoboPlan free mode uses terminal IK then joint planning +- **WHEN** RoboPlan-backed free Cartesian planning is requested +- **THEN** `RoboPlanWorld` MAY solve a final joint goal satisfying all Cartesian targets, run RoboPlan RRT from `start` to that goal, and verify final TCP poses before returning success diff --git a/pyproject.toml b/pyproject.toml index d8cb93fd74..e896c7edf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -613,6 +613,8 @@ ignore = [ "dimos/manipulation/manipulation_module.py", "dimos/manipulation/planning/world/drake_world.py", "dimos/manipulation/planning/world/roboplan_world.py", + "dimos/manipulation/test_roboplan_world.py", + "dimos/manipulation/visualization/viser/gui.py", "dimos/manipulation/visualization/viser/test_viser_visualization.py", "dimos/navigation/cmu_nav/modules/*/main.cpp", "dimos/navigation/cmu_nav/common/*.hpp", From c03f2a5fbd90b468d9b12d232728b315d20a86d5 Mon Sep 17 00:00:00 2001 From: cc Date: Sun, 28 Jun 2026 22:59:13 -0700 Subject: [PATCH 097/110] feat: add python project runtime environments --- CONTEXT.md | 16 + dimos/core/coordination/module_coordinator.py | 78 +- .../coordination/test_module_coordinator.py | 200 +- dimos/core/coordination/test_worker.py | 169 +- dimos/core/coordination/worker_launcher.py | 140 + dimos/core/runtime_environment.py | 111 + dimos/core/runtime_prepare.py | 181 + dimos/core/test_gpd_worker_demo.py | 132 + dimos/core/test_runtime_environment.py | 85 + dimos/robot/cli/dimos.py | 58 +- dimos/robot/cli/test_dimos.py | 273 +- docs/usage/README.md | 2 +- docs/usage/runtime_environments.md | 79 +- .../.openspec.yaml | 2 + .../design.md | 108 + .../proposal.md | 29 + .../runtime-environment-preparation/spec.md | 42 + .../runtime-environment-registry/spec.md | 31 + .../specs/venv-module-packaging/spec.md | 27 + .../specs/venv-module-placement/spec.md | 27 + .../tasks.md | 41 + packages/dimos-gpd-worker-demo/pixi.lock | 4704 +++++++++++++++++ packages/dimos-gpd-worker-demo/pixi.toml | 14 + packages/dimos-gpd-worker-demo/pyproject.toml | 19 + .../src/dimos_gpd_worker_demo/__init__.py | 11 + .../src/dimos_gpd_worker_demo/blueprint.py | 48 + packages/dimos-gpd-worker-demo/uv.lock | 4221 +++++++++++++++ pyproject.toml | 2 + 28 files changed, 10828 insertions(+), 22 deletions(-) create mode 100644 dimos/core/runtime_prepare.py create mode 100644 dimos/core/test_gpd_worker_demo.py create mode 100644 openspec/changes/python-project-runtime-environments/.openspec.yaml create mode 100644 openspec/changes/python-project-runtime-environments/design.md create mode 100644 openspec/changes/python-project-runtime-environments/proposal.md create mode 100644 openspec/changes/python-project-runtime-environments/specs/runtime-environment-preparation/spec.md create mode 100644 openspec/changes/python-project-runtime-environments/specs/runtime-environment-registry/spec.md create mode 100644 openspec/changes/python-project-runtime-environments/specs/venv-module-packaging/spec.md create mode 100644 openspec/changes/python-project-runtime-environments/specs/venv-module-placement/spec.md create mode 100644 openspec/changes/python-project-runtime-environments/tasks.md create mode 100644 packages/dimos-gpd-worker-demo/pixi.lock create mode 100644 packages/dimos-gpd-worker-demo/pixi.toml create mode 100644 packages/dimos-gpd-worker-demo/pyproject.toml create mode 100644 packages/dimos-gpd-worker-demo/src/dimos_gpd_worker_demo/__init__.py create mode 100644 packages/dimos-gpd-worker-demo/src/dimos_gpd_worker_demo/blueprint.py create mode 100644 packages/dimos-gpd-worker-demo/uv.lock diff --git a/CONTEXT.md b/CONTEXT.md index 7c17219585..7c24d90ab4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -96,6 +96,22 @@ _Avoid_: automatic venv creation, package installation plan, blueprint-embedded A runtime configuration map from stable environment names to environment backends that resolve interpreters, executables, command environment variables, and optional preparation steps for DimOS-managed processes. _Avoid_: venv-only config, blueprint-embedded machine paths, per-module ad hoc build commands +**Runtime environment preparation**: +An explicit pre-run action that prepares only the runtime environments used by active module placements in a loaded blueprint configuration. +_Avoid_: implicit install during blueprint run, global environment-name command, preparing unused registry entries + +**Python project runtime environment**: +A convention-driven runtime environment rooted at a Python project directory, where standard files such as `pyproject.toml`, `uv.lock`, and optionally `pixi.toml` determine how the worker Python environment is prepared and launched. +_Avoid_: per-tool runtime backend taxonomy, blueprint-embedded setup command, manually enumerated manifest paths + +**Pixi-backed uv runtime**: +A Python project runtime environment where Pixi prepares the native/toolchain layer and provides the Python interpreter used to create the project-local uv `.venv`; worker launch uses the `.venv` Python with Pixi activation environment applied. +_Avoid_: Pixi-only Python environment, coordinator Python venv, launching without native activation environment + +**Toolchain-mediated worker launch**: +A worker launch policy for convention-based Python project runtimes where DimOS invokes the project toolchain command, such as `pixi run uv run --no-sync python`, instead of reconstructing activation variables or launching the venv interpreter path directly. +_Avoid_: hand-built Pixi activation env, mutating sync during blueprint run, bypassing project runtime conventions + **Named runtime environment**: A stable label in the runtime environment registry that modules and worker placements reference when they need a non-default execution environment. _Avoid_: hardcoded venv path, Nix command string as identity, deployment type diff --git a/dimos/core/coordination/module_coordinator.py b/dimos/core/coordination/module_coordinator.py index ddfffec497..c04a6472ff 100644 --- a/dimos/core/coordination/module_coordinator.py +++ b/dimos/core/coordination/module_coordinator.py @@ -25,13 +25,17 @@ from typing import TYPE_CHECKING, Any, NamedTuple, cast from dimos.core.coordination.coordinator_rpc import CoordinatorRPC -from dimos.core.coordination.worker_launcher import VenvWorkerLauncher +from dimos.core.coordination.worker_launcher import CommandWorkerLauncher, VenvWorkerLauncher from dimos.core.coordination.worker_manager import WorkerManager from dimos.core.coordination.worker_manager_python import WorkerManagerPython from dimos.core.global_config import GlobalConfig, global_config from dimos.core.module import ModuleBase, ModuleSpec from dimos.core.resource import Resource -from dimos.core.runtime_environment import RuntimeEnvironmentRegistry +from dimos.core.runtime_environment import ( + PythonProjectRuntimeEnvironment, + PythonProjectRuntimeEnvironmentError, + RuntimeEnvironmentRegistry, +) from dimos.core.transport import LCMTransport, PubSubTransport, pLCMTransport from dimos.spec.utils import is_spec, spec_annotation_compliance, spec_structural_compliance from dimos.utils.generic import short_id @@ -201,7 +205,17 @@ def _ensure_venv_manager(self, env_name: str) -> str: if manager_key in self._managers: return manager_key try: - material = self._runtime_environment_registry.resolve(env_name).resolve_python() + environment = self._runtime_environment_registry.resolve(env_name) + except Exception as exc: + raise RuntimeError( + f"Module placement references runtime environment {env_name!r}, but it could not " + "be resolved as a Python runtime capability. Register a Python runtime environment on the " + "blueprint with .runtime_environments(...)." + ) from exc + if isinstance(environment, PythonProjectRuntimeEnvironment): + return self._ensure_project_manager(env_name, environment, manager_key) + try: + material = environment.resolve_python() except Exception as exc: raise RuntimeError( f"Module placement references runtime environment {env_name!r}, but it could not " @@ -234,6 +248,38 @@ def _ensure_venv_manager(self, env_name: str) -> str: self._managers[manager_key] = manager return manager_key + def _ensure_project_manager( + self, + env_name: str, + environment: PythonProjectRuntimeEnvironment, + manager_key: str, + ) -> str: + try: + material = environment.resolve_python_project() + except PythonProjectRuntimeEnvironmentError: + raise + except Exception as exc: + raise RuntimeError( + f"Module placement references runtime environment {env_name!r}, but it could not " + "be resolved as a Python project runtime capability. Register a Python project runtime " + "environment on the blueprint with .runtime_environments(...)." + ) from exc + manager = WorkerManagerPython( + g=self._global_config, + worker_launcher=CommandWorkerLauncher(material=material), + ) + if self._started: + try: + manager.start() + except Exception as exc: + manager.stop() + raise RuntimeError( + f"Failed to start runtime environment {env_name!r} Python project capability " + f"with command prefix {material.argv_prefix!r} in project {material.cwd!s}." + ) from exc + self._managers[manager_key] = manager + return manager_key + def _inject_native_runtime_registry( self, module_class: type[ModuleBase], kwargs: Mapping[str, Any] ) -> dict[str, Any]: @@ -305,11 +351,11 @@ def _deploy_group(dep: str) -> None: if dep.startswith("python:"): env_name = dep.removeprefix("python:") try: - executable = str( - self._runtime_environment_registry.resolve(env_name) - .resolve_python() - .python_executable - ) + environment = self._runtime_environment_registry.resolve(env_name) + if isinstance(environment, PythonProjectRuntimeEnvironment): + executable = " ".join(environment.resolve_python_project().argv_prefix) + else: + executable = str(environment.resolve_python().python_executable) except Exception: executable = "" raise RuntimeError( @@ -430,14 +476,18 @@ def build( coordinator = cls(g=global_config) coordinator.start() - _deploy_all_modules(blueprint, coordinator, global_config, blueprint_args) - coordinator._connect_streams(blueprint) - _connect_module_refs(blueprint, coordinator) + try: + _deploy_all_modules(blueprint, coordinator, global_config, blueprint_args) + coordinator._connect_streams(blueprint) + _connect_module_refs(blueprint, coordinator) - coordinator.build_all_modules() - coordinator.start_all_modules() + coordinator.build_all_modules() + coordinator.start_all_modules() - _log_blueprint_graph(blueprint, coordinator) + _log_blueprint_graph(blueprint, coordinator) + except Exception: + coordinator.stop() + raise return coordinator diff --git a/dimos/core/coordination/test_module_coordinator.py b/dimos/core/coordination/test_module_coordinator.py index 145e9308f1..2087c6654e 100644 --- a/dimos/core/coordination/test_module_coordinator.py +++ b/dimos/core/coordination/test_module_coordinator.py @@ -12,11 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +from dataclasses import dataclass +import os from pathlib import Path import sys from types import MappingProxyType from typing import Protocol +import psutil import pytest from dimos.core._test_future_annotations_helper import ( @@ -39,7 +42,13 @@ from dimos.core.core import rpc from dimos.core.global_config import GlobalConfig from dimos.core.module import Module -from dimos.core.runtime_environment import PythonVenvRuntimeEnvironment +from dimos.core.runtime_environment import ( + MissingPythonProjectFileError, + PythonLaunchMaterial, + PythonProjectRuntimeEnvironment, + PythonVenvRuntimeEnvironment, + RuntimeEnvironment, +) from dimos.core.stream import In, Out from dimos.msgs.sensor_msgs.Image import Image from dimos.spec.utils import Spec @@ -589,6 +598,26 @@ def _sys_python_env(name: str) -> PythonVenvRuntimeEnvironment: return PythonVenvRuntimeEnvironment(name=name, python_executable=Path(sys.executable)) +def _prepared_project(tmp_path: Path) -> PythonProjectRuntimeEnvironment: + project = tmp_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text("[project]\nname='demo'\nversion='0.0.0'\n") + python_path = project / ".venv" / "bin" / "python" + python_path.parent.mkdir(parents=True) + python_path.write_text("prepared") + return PythonProjectRuntimeEnvironment(name="project-env", project=project) + + +def _write_fake_uv(path: Path) -> None: + path.write_text( + "#!/usr/bin/env python3\n" + "import subprocess, sys\n" + "assert sys.argv[1:4] == ['run', '--no-sync', 'python']\n" + f"raise SystemExit(subprocess.call([{sys.executable!r}, *sys.argv[4:]]))\n" + ) + path.chmod(0o755) + + def test_unplaced_modules_use_default_pool(build_coordinator) -> None: coordinator = build_coordinator(ModuleA.blueprint()) @@ -613,6 +642,77 @@ def test_same_named_env_modules_share_venv_pool(build_coordinator) -> None: assert len(manager.workers) == 1 +def test_direct_venv_uses_venv_worker_launcher(dynamic_coordinator) -> None: + blueprint = ( + ModuleA.blueprint() + .runtime_environments(_sys_python_env("env-a")) + .runtime_placements({ModuleA: "env-a"}) + ) + + dynamic_coordinator.load_blueprint(blueprint) + + manager = dynamic_coordinator._managers["python:env-a"] + assert isinstance(manager, WorkerManagerPython) + worker = manager.workers[0] + assert type(worker._launcher).__name__ == "VenvWorkerLauncher" + + +def test_current_runtime_placement_uses_python_launcher(dynamic_coordinator) -> None: + blueprint = ModuleA.blueprint().runtime_placements({ModuleA: "current"}) + + dynamic_coordinator.load_blueprint(blueprint) + + manager = dynamic_coordinator._managers["python:current"] + assert isinstance(manager, WorkerManagerPython) + worker = manager.workers[0] + assert type(worker._launcher).__name__ == "VenvWorkerLauncher" + + +@dataclass(frozen=True) +class CustomPythonRuntimeEnvironment(RuntimeEnvironment): + name: str = "custom-python" + + def resolve_python(self) -> PythonLaunchMaterial: + return PythonLaunchMaterial( + python_executable=Path(sys.executable), + env={"DIMOS_TEST_CUSTOM_RUNTIME": "1"}, + ) + + +def test_custom_python_capability_runtime_uses_python_launcher(dynamic_coordinator) -> None: + blueprint = ( + ModuleA.blueprint() + .runtime_environments(CustomPythonRuntimeEnvironment()) + .runtime_placements({ModuleA: "custom-python"}) + ) + + dynamic_coordinator.load_blueprint(blueprint) + + manager = dynamic_coordinator._managers["python:custom-python"] + assert isinstance(manager, WorkerManagerPython) + worker = manager.workers[0] + assert type(worker._launcher).__name__ == "VenvWorkerLauncher" + + +def test_project_runtime_uses_command_worker_launcher( + dynamic_coordinator, tmp_path, monkeypatch +) -> None: + _write_fake_uv(tmp_path / "uv") + monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ.get('PATH', '')}") + blueprint = ( + ModuleA.blueprint() + .runtime_environments(_prepared_project(tmp_path)) + .runtime_placements({ModuleA: "project-env"}) + ) + + dynamic_coordinator.load_blueprint(blueprint) + + manager = dynamic_coordinator._managers["python:project-env"] + assert isinstance(manager, WorkerManagerPython) + worker = manager.workers[0] + assert type(worker._launcher).__name__ == "CommandWorkerLauncher" + + def test_distinct_named_env_modules_use_distinct_venv_pools(build_coordinator) -> None: blueprint = ( autoconnect(ModuleA.blueprint(), ModuleB.blueprint()) @@ -772,6 +872,104 @@ def test_missing_venv_executable_error_includes_env_and_does_not_linger( assert "python:bad-env" not in dynamic_coordinator._managers +def test_missing_project_prepared_python_fails_before_manager_and_popen( + dynamic_coordinator, tmp_path, mocker +) -> None: + project = tmp_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text("[project]\nname='demo'\nversion='0.0.0'\n") + env = PythonProjectRuntimeEnvironment(name="project-env", project=project) + popen = mocker.patch("dimos.core.coordination.worker_launcher.subprocess.Popen") + blueprint = ( + ModuleA.blueprint().runtime_environments(env).runtime_placements({ModuleA: "project-env"}) + ) + + with pytest.raises(Exception) as exc_info: + dynamic_coordinator.load_blueprint(blueprint) + + message = str(exc_info.value) + assert "project-env" in message + assert str(project) in message + assert str(project / ".venv" / "bin" / "python") in message + assert "dimos runtime prepare --runtime project-env" in message + assert "python:project-env" not in dynamic_coordinator._managers + popen.assert_not_called() + + +def test_missing_project_pyproject_fails_actionably_before_manager_and_popen( + dynamic_coordinator, tmp_path, mocker +) -> None: + project = tmp_path / "project-without-pyproject" + project.mkdir() + env = PythonProjectRuntimeEnvironment(name="project-env", project=project) + popen = mocker.patch("dimos.core.coordination.worker_launcher.subprocess.Popen") + blueprint = ( + ModuleA.blueprint().runtime_environments(env).runtime_placements({ModuleA: "project-env"}) + ) + + with pytest.raises(MissingPythonProjectFileError) as exc_info: + dynamic_coordinator.load_blueprint(blueprint) + + message = str(exc_info.value) + assert "project-env" in message + assert str(project) in message + assert "pyproject.toml" in message + assert "python:project-env" not in dynamic_coordinator._managers + popen.assert_not_called() + + +@pytest.mark.skipif_macos_bug +def test_build_missing_project_prepared_python_stops_default_workers_and_no_popen( + tmp_path, mocker +) -> None: + project = tmp_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text("[project]\nname='demo'\nversion='0.0.0'\n") + env = PythonProjectRuntimeEnvironment(name="project-env", project=project) + blueprint = ( + ModuleA.blueprint() + .global_config(n_workers=1) + .runtime_environments(env) + .runtime_placements({ModuleA: "project-env"}) + ) + constructed: list[ModuleCoordinator] = [] + original_init = ModuleCoordinator.__init__ + + def track_init(self: ModuleCoordinator, g: GlobalConfig) -> None: + original_init(self, g) + constructed.append(self) + + mocker.patch.object(ModuleCoordinator, "__init__", track_init) + popen = mocker.patch("dimos.core.coordination.worker_launcher.subprocess.Popen") + + with pytest.raises(Exception) as exc_info: + ModuleCoordinator.build(blueprint, _BUILD_WITHOUT_RERUN.copy()) + + assert "project-env" in str(exc_info.value) + popen.assert_not_called() + assert len(constructed) == 1 + assert "python:project-env" not in constructed[0]._managers + python_manager = constructed[0]._managers["python"] + assert isinstance(python_manager, WorkerManagerPython) + worker_pids = [worker.pid for worker in python_manager.workers if worker.pid is not None] + assert all(not psutil.pid_exists(pid) for pid in worker_pids) + + +def test_disabled_unused_project_runtime_placement_does_not_allocate_pool( + dynamic_coordinator, tmp_path +) -> None: + blueprint = ( + autoconnect(ModuleA.blueprint(), ModuleB.blueprint()) + .disabled_modules(ModuleB) + .runtime_environments(_prepared_project(tmp_path)) + .runtime_placements({ModuleB: "project-env"}) + ) + + dynamic_coordinator.load_blueprint(blueprint) + + assert "python:project-env" not in dynamic_coordinator._managers + + def test_check_requirements_failure(mocker) -> None: """A failing requirement check causes sys.exit.""" mocker.patch("dimos.core.coordination.module_coordinator.sys.exit", side_effect=SystemExit(1)) diff --git a/dimos/core/coordination/test_worker.py b/dimos/core/coordination/test_worker.py index 60b6501fd1..82f99e90af 100644 --- a/dimos/core/coordination/test_worker.py +++ b/dimos/core/coordination/test_worker.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import signal import sys import time from typing import TYPE_CHECKING @@ -20,11 +22,12 @@ import pytest from dimos.core.coordination.python_worker import PythonWorker, reset_forkserver_context -from dimos.core.coordination.worker_launcher import VenvWorkerLauncher +from dimos.core.coordination.worker_launcher import CommandWorkerLauncher, VenvWorkerLauncher from dimos.core.coordination.worker_manager_python import WorkerManagerPython from dimos.core.core import rpc from dimos.core.global_config import GlobalConfig, global_config from dimos.core.module import Module +from dimos.core.runtime_environment import PythonProjectLaunchMaterial from dimos.core.stream import In, Out from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -156,6 +159,170 @@ def test_venv_worker_missing_python_executable() -> None: worker.start_process() +def _write_fake_uv(path, python_executable: str = sys.executable) -> None: + path.write_text( + "#!/usr/bin/env python3\n" + "import os, subprocess, sys\n" + "assert sys.argv[1:4] == ['run', '--no-sync', 'python']\n" + "assert os.environ.get('DIMOS_TEST_SENTINEL') == 'kept'\n" + "assert os.getcwd() == os.environ.get('DIMOS_EXPECTED_CWD')\n" + f"raise SystemExit(subprocess.call([{python_executable!r}, *sys.argv[4:]]))\n" + ) + path.chmod(0o755) + + +def _write_fake_pixi(path) -> None: + path.write_text( + "#!/usr/bin/env python3\n" + "import os, subprocess, sys\n" + "assert sys.argv[1:3] == ['run', 'uv']\n" + "uv = os.path.join(os.path.dirname(__file__), 'uv')\n" + "raise SystemExit(subprocess.call([uv, *sys.argv[3:]]))\n" + ) + path.chmod(0o755) + + +@pytest.mark.skipif_macos_bug +def test_command_worker_launch_deploy_rpc_shutdown_with_fake_uv(tmp_path, monkeypatch) -> None: + _write_fake_uv(tmp_path / "uv") + monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ.get('PATH', '')}") + monkeypatch.setenv("DIMOS_RUN_SURVIVES", "1") + material = PythonProjectLaunchMaterial( + argv_prefix=["uv", "run", "--no-sync", "python"], + cwd=tmp_path, + env={"DIMOS_TEST_SENTINEL": "kept", "DIMOS_EXPECTED_CWD": str(tmp_path)}, + ) + manager = WorkerManagerPython( + g=GlobalConfig(n_workers=1), + worker_launcher=CommandWorkerLauncher(material, startup_timeout=5.0), + ) + try: + module = manager.deploy(SimpleModule, global_config, {}) + module.start() + assert module.increment() == 1 + module.stop() + finally: + manager.stop() + + +@pytest.mark.skipif_macos_bug +def test_command_worker_launch_deploy_rpc_shutdown_with_fake_pixi(tmp_path, monkeypatch) -> None: + _write_fake_uv(tmp_path / "uv") + _write_fake_pixi(tmp_path / "pixi") + monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ.get('PATH', '')}") + material = PythonProjectLaunchMaterial( + argv_prefix=["pixi", "run", "uv", "run", "--no-sync", "python"], + cwd=tmp_path, + env={"DIMOS_TEST_SENTINEL": "kept", "DIMOS_EXPECTED_CWD": str(tmp_path)}, + ) + manager = WorkerManagerPython( + g=GlobalConfig(n_workers=1), + worker_launcher=CommandWorkerLauncher(material, startup_timeout=5.0), + ) + try: + module = manager.deploy(SimpleModule, global_config, {}) + assert module.increment() == 1 + module.stop() + finally: + manager.stop() + + +@pytest.mark.skipif_macos_bug +def test_command_worker_timeout_terminates_wrapper_child(tmp_path) -> None: + marker = tmp_path / "orphan-child-marker" + wrapper = tmp_path / "wrapper" + wrapper.write_text( + "#!/usr/bin/env python3\n" + "import subprocess, sys, time\n" + f"subprocess.Popen([sys.executable, '-c', \"import pathlib, time; pathlib.Path({str(marker)!r}).touch(); time.sleep(30)\"])\n" + "time.sleep(30)\n" + ) + wrapper.chmod(0o755) + material = PythonProjectLaunchMaterial(argv_prefix=[str(wrapper)], cwd=tmp_path) + worker = PythonWorker(launcher=CommandWorkerLauncher(material, startup_timeout=0.2)) + + with pytest.raises(TimeoutError): + worker.start_process() + + deadline = time.monotonic() + 3.0 + while not marker.exists() and time.monotonic() < deadline: + time.sleep(0.05) + time.sleep(0.2) + assert not any( + str(marker) in " ".join(proc.info.get("cmdline") or []) + for proc in psutil.process_iter(["cmdline"]) + ) + + +@pytest.mark.skipif_macos_bug +def test_command_worker_exit_before_connect_kills_sigterm_ignoring_child(tmp_path) -> None: + marker = tmp_path / "sigterm-ignoring-child-marker" + wrapper = tmp_path / "wrapper" + child_script = ( + "import pathlib, signal, time; " + f"pathlib.Path({str(marker)!r}).touch(); " + "signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "time.sleep(30)" + ) + wrapper.write_text( + "#!/usr/bin/env python3\n" + "import subprocess, sys, time\n" + f"subprocess.Popen([sys.executable, '-c', {child_script!r}])\n" + "while not " + f"__import__('pathlib').Path({str(marker)!r}).exists():\n" + " time.sleep(0.01)\n" + "raise SystemExit(42)\n" + ) + wrapper.chmod(0o755) + material = PythonProjectLaunchMaterial(argv_prefix=[str(wrapper)], cwd=tmp_path) + worker = PythonWorker(launcher=CommandWorkerLauncher(material, startup_timeout=2.0)) + + with pytest.raises(RuntimeError, match="exit code 42"): + worker.start_process() + + assert marker.exists() + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + if not any( + str(marker) in " ".join(proc.info.get("cmdline") or []) + for proc in psutil.process_iter(["cmdline"]) + ): + break + time.sleep(0.05) + assert not any( + str(marker) in " ".join(proc.info.get("cmdline") or []) + for proc in psutil.process_iter(["cmdline"]) + ) + + +@pytest.mark.skipif_macos_bug +def test_command_worker_shutdown_fallback_kills_stopped_process_group( + tmp_path, monkeypatch +) -> None: + _write_fake_uv(tmp_path / "uv") + monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ.get('PATH', '')}") + material = PythonProjectLaunchMaterial( + argv_prefix=["uv", "run", "--no-sync", "python"], + cwd=tmp_path, + env={"DIMOS_TEST_SENTINEL": "kept", "DIMOS_EXPECTED_CWD": str(tmp_path)}, + ) + manager = WorkerManagerPython( + g=GlobalConfig(n_workers=1), + worker_launcher=CommandWorkerLauncher(material, startup_timeout=5.0), + ) + manager.start() + pid = manager.workers[0].pid + assert pid is not None + os.killpg(pid, signal.SIGSTOP) + + manager.stop() + + deadline = time.monotonic() + 3.0 + while psutil.pid_exists(pid) and time.monotonic() < deadline: + time.sleep(0.05) + assert not psutil.pid_exists(pid) + + @pytest.mark.skipif_macos_bug def test_venv_worker_connection_timeout_cleanup(tmp_path) -> None: sleeper = tmp_path / "sleepy-python" diff --git a/dimos/core/coordination/worker_launcher.py b/dimos/core/coordination/worker_launcher.py index 435866c44b..29b741b1b9 100644 --- a/dimos/core/coordination/worker_launcher.py +++ b/dimos/core/coordination/worker_launcher.py @@ -17,12 +17,14 @@ from multiprocessing.connection import Connection, Listener, wait import os import secrets +import signal import subprocess import tempfile import time from typing import Protocol from dimos.core.coordination.worker_messages import WorkerRequest, WorkerResponse +from dimos.core.runtime_environment import PythonProjectLaunchMaterial class WorkerProcessHandle(Protocol): @@ -218,3 +220,141 @@ def _accept_with_timeout( f"Venv worker exited during startup with exit code {process.returncode}" ) return result + + +@dataclass +class CommandWorkerProcessHandle(VenvWorkerProcessHandle): + process_group_id: int + + def terminate(self) -> None: + _terminate_process_group(self.process, self.process_group_id, terminate_timeout=0.5) + + +def _process_group_exists(process_group_id: int) -> bool: + try: + os.killpg(process_group_id, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _terminate_process_group( + process: subprocess.Popen[bytes], + process_group_id: int, + *, + terminate_timeout: float, +) -> None: + if _process_group_exists(process_group_id): + try: + os.killpg(process_group_id, signal.SIGTERM) + except ProcessLookupError: + pass + except OSError: + if process.poll() is None: + process.terminate() + + deadline = time.monotonic() + terminate_timeout + while time.monotonic() < deadline: + if process.poll() is not None and not _process_group_exists(process_group_id): + return + time.sleep(0.02) + + if _process_group_exists(process_group_id): + try: + os.killpg(process_group_id, signal.SIGKILL) + except ProcessLookupError: + pass + except OSError: + if process.poll() is None: + process.kill() + + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + except ChildProcessError: + pass + + +class CommandWorkerLauncher: + def __init__( + self, + material: PythonProjectLaunchMaterial, + startup_timeout: float = 10.0, + ) -> None: + self.material = material + self.startup_timeout = startup_timeout + + def launch(self, worker_id: int) -> WorkerProcessHandle: + with tempfile.TemporaryDirectory(prefix="dimos-project-worker-") as temp_dir: + address = os.path.join(temp_dir, "worker.sock") + authkey = secrets.token_bytes(32) + listener = Listener(address=address, family="AF_UNIX", authkey=authkey) + process: subprocess.Popen[bytes] | None = None + try: + child_env = os.environ.copy() + child_env.update(self.material.env) + argv = [ + *self.material.argv_prefix, + "-m", + "dimos.core.coordination.venv_worker_entrypoint", + "--address", + address, + "--authkey-hex", + authkey.hex(), + "--worker-id", + str(worker_id), + ] + process = subprocess.Popen( + argv, + cwd=self.material.cwd, + env=child_env, + start_new_session=True, + ) + process_group_id = os.getpgid(process.pid) + conn = self._accept_with_timeout(listener, process) + return CommandWorkerProcessHandle( + process=process, connection=conn, process_group_id=process_group_id + ) + except Exception: + if process is not None: + self._terminate_process_group(process) + raise + finally: + listener.close() + + def _accept_with_timeout( + self, listener: Listener, process: subprocess.Popen[bytes] + ) -> Connection: + deadline = time.monotonic() + self.startup_timeout + while True: + returncode = process.poll() + if returncode is not None: + command = str(process.args) + raise RuntimeError( + "Command worker exited before connecting " + f"with exit code {returncode}. Command: {command}" + ) + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"Timed out waiting {self.startup_timeout}s for command worker to connect" + ) + + ready = wait([listener._listener._socket], timeout=min(0.05, remaining)) # type: ignore[attr-defined] + if ready: + break + + result = listener.accept() + if process.poll() is not None: + result.close() + raise RuntimeError( + f"Command worker exited during startup with exit code {process.returncode}" + ) + return result + + def _terminate_process_group(self, process: subprocess.Popen[bytes]) -> None: + _terminate_process_group(process, process.pid, terminate_timeout=2) diff --git a/dimos/core/runtime_environment.py b/dimos/core/runtime_environment.py index b338438871..fe7dc92576 100644 --- a/dimos/core/runtime_environment.py +++ b/dimos/core/runtime_environment.py @@ -25,6 +25,17 @@ class PythonLaunchMaterial: env: dict[str, str] = field(default_factory=dict) +@dataclass(frozen=True) +class PythonProjectLaunchMaterial: + argv_prefix: list[str] + cwd: Path + env: dict[str, str] = field(default_factory=dict) + runtime_name: str = "" + project: Path = Path() + convention: str = "uv" + prepared_python: Path = Path() + + @dataclass(frozen=True) class NativeLaunchMaterial: executable: str @@ -41,6 +52,11 @@ def resolve_python(self) -> PythonLaunchMaterial: f"Runtime environment '{self.name}' does not provide Python launch material" ) + def resolve_python_project(self) -> PythonProjectLaunchMaterial: + raise RuntimeError( + f"Runtime environment '{self.name}' does not provide Python project launch material" + ) + def resolve_native(self) -> NativeLaunchMaterial: raise RuntimeError( f"Runtime environment '{self.name}' does not provide native launch material" @@ -65,6 +81,101 @@ def resolve_python(self) -> PythonLaunchMaterial: return PythonLaunchMaterial(python_executable=self.python_executable, env=dict(self.env)) +class PythonProjectRuntimeEnvironmentError(RuntimeError): + """Base error for invalid Python project runtime environments.""" + + +class MissingPythonProjectFileError(PythonProjectRuntimeEnvironmentError): + def __init__(self, *, runtime_name: str, project: Path, missing_file: Path) -> None: + self.runtime_name = runtime_name + self.project = project + self.missing_file = missing_file + super().__init__( + "Python project runtime " + f"'{runtime_name}' at '{project}' is missing required project file " + f"'{missing_file}'. First-slice Python project runtimes require pyproject.toml." + ) + + +class MissingPreparedPythonProjectError(PythonProjectRuntimeEnvironmentError): + def __init__( + self, + *, + runtime_name: str, + project: Path, + missing_executable: Path, + convention: str, + ) -> None: + self.runtime_name = runtime_name + self.project = project + self.missing_executable = missing_executable + self.convention = convention + prepare_command = f"dimos runtime prepare --runtime {runtime_name}" + super().__init__( + "Python project runtime " + f"'{runtime_name}' at '{project}' is not prepared; missing executable " + f"'{missing_executable}'. Detected convention: {convention}. " + f"Prepare it with: {prepare_command}" + ) + + +@dataclass(frozen=True) +class PythonProjectRuntimeEnvironment(RuntimeEnvironment): + name: str + project: Path + + def __post_init__(self) -> None: + object.__setattr__(self, "project", Path(self.project).expanduser().resolve()) + + @property + def pyproject_toml(self) -> Path: + return self.project / "pyproject.toml" + + @property + def pixi_toml(self) -> Path: + return self.project / "pixi.toml" + + @property + def prepared_python(self) -> Path: + return self.project / ".venv" / "bin" / "python" + + def convention(self) -> str: + self._validate_project_file() + if self.pixi_toml.exists(): + return "pixi-backed-uv" + return "uv" + + def resolve_python_project(self) -> PythonProjectLaunchMaterial: + convention = self.convention() + if not self.prepared_python.exists(): + raise MissingPreparedPythonProjectError( + runtime_name=self.name, + project=self.project, + missing_executable=self.prepared_python, + convention=convention, + ) + argv_prefix = ["uv", "run", "--no-sync", "python"] + if convention == "pixi-backed-uv": + argv_prefix = ["pixi", "run", *argv_prefix] + return PythonProjectLaunchMaterial( + argv_prefix=argv_prefix, + cwd=self.project, + env={}, + runtime_name=self.name, + project=self.project, + convention=convention, + prepared_python=self.prepared_python, + ) + + def _validate_project_file(self) -> None: + if not self.pyproject_toml.exists(): + raise MissingPythonProjectFileError( + runtime_name=self.name, + project=self.project, + missing_file=self.pyproject_toml, + ) + + @dataclass(frozen=True) class NativeRuntimeEnvironment(RuntimeEnvironment): name: str diff --git a/dimos/core/runtime_prepare.py b/dimos/core/runtime_prepare.py new file mode 100644 index 0000000000..aaf61f7688 --- /dev/null +++ b/dimos/core/runtime_prepare.py @@ -0,0 +1,181 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +import subprocess + +from dimos.core.coordination.blueprints import Blueprint +from dimos.core.runtime_environment import ( + PythonProjectRuntimeEnvironment, + PythonProjectRuntimeEnvironmentError, + PythonVenvRuntimeEnvironment, +) + + +class RuntimePrepareError(RuntimeError): + """Raised when runtime preparation cannot select or prepare a runtime.""" + + +@dataclass(frozen=True) +class RuntimePrepareCommand: + runtime_name: str + project: Path + convention: str + argv: list[str] + + +@dataclass(frozen=True) +class RuntimePreparePlan: + commands: list[RuntimePrepareCommand] + no_op_runtime_names: list[str] + + +SubprocessRunner = Callable[..., subprocess.CompletedProcess[object]] + + +def select_runtime_prepare_plan( + blueprint: Blueprint, runtime_name: str | None = None +) -> RuntimePreparePlan: + active_module_classes = {atom.module for atom in blueprint.active_blueprints} + active_placements = { + module_class: placed_runtime_name + for module_class, placed_runtime_name in blueprint.runtime_placement_map.items() + if module_class in active_module_classes + } + active_runtime_names = sorted(set(active_placements.values())) + registry = blueprint.runtime_environment_registry + + if runtime_name is not None: + if runtime_name in active_runtime_names and runtime_name not in registry.environments: + raise _missing_active_runtime_error(runtime_name, active_placements) + try: + registry.resolve(runtime_name) + except KeyError as exc: + raise RuntimePrepareError(str(exc)) from exc + if runtime_name not in active_runtime_names: + used = ", ".join(active_runtime_names) or "" + raise RuntimePrepareError( + f"Runtime environment '{runtime_name}' is registered but is not used by the " + f"active blueprint configuration. Active placed runtimes: {used}" + ) + active_runtime_names = [runtime_name] + + commands: list[RuntimePrepareCommand] = [] + no_op_runtime_names: list[str] = [] + for active_runtime_name in active_runtime_names: + try: + environment = registry.resolve(active_runtime_name) + except KeyError as exc: + raise _missing_active_runtime_error(active_runtime_name, active_placements) from exc + if isinstance(environment, PythonProjectRuntimeEnvironment): + try: + commands.extend(_commands_for_project_runtime(environment)) + except PythonProjectRuntimeEnvironmentError as exc: + raise RuntimePrepareError(str(exc)) from exc + elif isinstance(environment, PythonVenvRuntimeEnvironment): + no_op_runtime_names.append(active_runtime_name) + else: + # Current-process/native/etc. placements are not Python project runtimes and have no + # preparation contract in this change. + no_op_runtime_names.append(active_runtime_name) + return RuntimePreparePlan(commands=commands, no_op_runtime_names=no_op_runtime_names) + + +def _missing_active_runtime_error( + runtime_name: str, + active_placements: dict[type, str], +) -> RuntimePrepareError: + modules = sorted( + module_class.__name__ + for module_class, placed_runtime_name in active_placements.items() + if placed_runtime_name == runtime_name + ) + module_list = ", ".join(modules) or "" + return RuntimePrepareError( + f"Active runtime environment '{runtime_name}' is used by module(s) " + f"{module_list}, but it is not registered on the blueprint." + ) + + +def prepare_runtime_plan( + plan: RuntimePreparePlan, + *, + runner: SubprocessRunner | None = None, + output: Callable[[str], None] = print, +) -> None: + run_command = runner or subprocess.run + for runtime_name in plan.no_op_runtime_names: + output( + f"Runtime '{runtime_name}' is an active direct/external environment; no prepare step is required." + ) + + for command in plan.commands: + output( + f"Preparing runtime '{command.runtime_name}' " + f"(convention: {command.convention}, project: {command.project})" + ) + output(f"Running: {' '.join(command.argv)}") + try: + run_command(command.argv, check=True, cwd=command.project) + except (OSError, subprocess.CalledProcessError) as exc: + raise RuntimePrepareError( + f"Failed preparing runtime '{command.runtime_name}' at '{command.project}' " + f"with command {command.argv!r}" + ) from exc + + +def prepare_blueprint_runtimes( + blueprint: Blueprint, + runtime_name: str | None = None, + *, + runner: SubprocessRunner | None = None, + output: Callable[[str], None] = print, +) -> RuntimePreparePlan: + plan = select_runtime_prepare_plan(blueprint, runtime_name) + prepare_runtime_plan(plan, runner=runner, output=output) + return plan + + +def _commands_for_project_runtime( + environment: PythonProjectRuntimeEnvironment, +) -> list[RuntimePrepareCommand]: + convention = environment.convention() + if convention == "pixi-backed-uv": + argv_by_step = [ + ["pixi", "install"], + [ + "pixi", + "run", + "uv", + "venv", + "-p", + ".pixi/envs/default/bin/python", + "--seed", + ], + ["pixi", "run", "uv", "sync"], + ] + else: + argv_by_step = [["uv", "venv", "--seed"], ["uv", "sync"]] + return [ + RuntimePrepareCommand( + runtime_name=environment.name, + project=environment.project, + convention=convention, + argv=argv, + ) + for argv in argv_by_step + ] diff --git a/dimos/core/test_gpd_worker_demo.py b/dimos/core/test_gpd_worker_demo.py new file mode 100644 index 0000000000..fdbc23b893 --- /dev/null +++ b/dimos/core/test_gpd_worker_demo.py @@ -0,0 +1,132 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from pathlib import Path +import shutil +import sys +from types import ModuleType + +import pytest + +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment + +_BUILD_WITHOUT_RERUN = {"g": {"viewer": "none", "n_workers": 1}} +_REPO_ROOT = Path(__file__).resolve().parents[2] +_GPD_DEMO_PROJECT = _REPO_ROOT / "packages" / "dimos-gpd-worker-demo" +_GPD_DEMO_SRC = _GPD_DEMO_PROJECT / "src" +_GPD_COMMIT = "c088d8ae2f7965b067e9a12b3c0dacdbe9da924a" + + +@pytest.fixture +def gpd_demo_import_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.syspath_prepend(str(_GPD_DEMO_SRC)) + sys.modules.pop("dimos_gpd_worker_demo", None) + sys.modules.pop("dimos_gpd_worker_demo.blueprint", None) + + +def test_gpd_demo_package_pins_gpd_dependency() -> None: + pyproject = (_GPD_DEMO_PROJECT / "pyproject.toml").read_text() + assert '"dimos"' in pyproject + assert 'dimos = { path = "../..", editable = true }' in pyproject + assert "gpd @ git+https://github.com/TomCC7/gpd.git" in pyproject + assert _GPD_COMMIT in pyproject + + +def test_gpd_demo_pixi_project_has_native_build_dependencies() -> None: + pixi = (_GPD_DEMO_PROJECT / "pixi.toml").read_text() + + for package in ( + "cmake", + "compilers", + "eigen", + "opencv", + "pcl", + "pkg-config", + "python", + "uv", + ): + assert f"{package} = " in pixi + + +def test_gpd_demo_blueprint_imports_safely_and_places_project_runtime( + gpd_demo_import_path: None, +) -> None: + from dimos_gpd_worker_demo import GPD_DEMO_ENV_NAME, GpdImportProbe, gpd_worker_demo_blueprint + + blueprint = gpd_worker_demo_blueprint() + environment = blueprint.runtime_environment_registry.environments[GPD_DEMO_ENV_NAME] + + assert isinstance(environment, PythonProjectRuntimeEnvironment) + assert environment.project == _GPD_DEMO_PROJECT.resolve() + assert blueprint.runtime_placement_map[GpdImportProbe] == GPD_DEMO_ENV_NAME + + +def test_gpd_import_rpc_lazily_imports_stubbed_gpd_core( + gpd_demo_import_path: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from dimos_gpd_worker_demo import GpdImportProbe + + gpd_module = ModuleType("gpd") + gpd_core_module = ModuleType("gpd.core") + gpd_core_module.__file__ = "/stub/gpd/core.py" + monkeypatch.setitem(sys.modules, "gpd", gpd_module) + monkeypatch.setitem(sys.modules, "gpd.core", gpd_core_module) + + result = GpdImportProbe.import_gpd_core(object()) + + assert result == "gpd import ok: gpd.core (/stub/gpd/core.py)" + + +@pytest.mark.skipif(shutil.which("pixi") is None, reason="Pixi is not installed") +def test_gpd_demo_pixi_project_runtime_resolves_when_prepared( + gpd_demo_import_path: None, +) -> None: + prepared_python = _GPD_DEMO_PROJECT / ".venv" / "bin" / "python" + if not prepared_python.exists(): + pytest.skip("GPD demo project runtime is not prepared") + + from dimos_gpd_worker_demo import GPD_DEMO_ENV_NAME, gpd_worker_demo_blueprint + + environment = gpd_worker_demo_blueprint().runtime_environment_registry.environments[ + GPD_DEMO_ENV_NAME + ] + material = environment.resolve_python_project() + + assert material.argv_prefix == ["pixi", "run", "uv", "run", "--no-sync", "python"] + assert material.cwd == _GPD_DEMO_PROJECT.resolve() + assert material.prepared_python == prepared_python + + +@pytest.mark.skipif(shutil.which("pixi") is None, reason="Pixi is not installed") +def test_gpd_demo_pixi_project_runtime_imports_gpd_when_prepared( + gpd_demo_import_path: None, +) -> None: + prepared_python = _GPD_DEMO_PROJECT / ".venv" / "bin" / "python" + if not prepared_python.exists(): + pytest.skip("GPD demo project runtime is not prepared") + + from dimos_gpd_worker_demo import GpdImportProbe, gpd_worker_demo_blueprint + + coordinator = ModuleCoordinator.build( + gpd_worker_demo_blueprint(), + _BUILD_WITHOUT_RERUN.copy(), + ) + try: + probe = coordinator.get_instance(GpdImportProbe) + assert probe.import_gpd_core().startswith("gpd import ok: gpd.core (") + finally: + coordinator.stop() diff --git a/dimos/core/test_runtime_environment.py b/dimos/core/test_runtime_environment.py index 3ca160477c..89d32a144f 100644 --- a/dimos/core/test_runtime_environment.py +++ b/dimos/core/test_runtime_environment.py @@ -18,8 +18,11 @@ from dimos.core.runtime_environment import ( CurrentProcessRuntimeEnvironment, + MissingPreparedPythonProjectError, + MissingPythonProjectFileError, NativeRuntimeEnvironment, NixNativeRuntimeEnvironment, + PythonProjectRuntimeEnvironment, PythonVenvRuntimeEnvironment, RuntimeEnvironmentRegistry, ) @@ -32,6 +35,88 @@ def test_register_and_resolve_environment() -> None: assert registry.resolve("tools") is env +def test_registry_resolves_python_project_runtime_environment(tmp_path: Path) -> None: + env = PythonProjectRuntimeEnvironment(name="worker", project=tmp_path) + registry = RuntimeEnvironmentRegistry.with_current_process().register(env) + + assert registry.resolve("worker") is env + + +def test_python_project_runtime_is_convention_only_and_normalizes_project_path( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + project.mkdir() + env = PythonProjectRuntimeEnvironment(name="worker", project=project / ".." / "project") + + assert env.name == "worker" + assert env.project == project.resolve() + assert set(env.__dataclass_fields__) == {"name", "project"} + + +def test_python_project_missing_pyproject_error_is_actionable(tmp_path: Path) -> None: + env = PythonProjectRuntimeEnvironment(name="worker", project=tmp_path) + missing_file = tmp_path / "pyproject.toml" + + with pytest.raises(MissingPythonProjectFileError) as exc_info: + env.resolve_python_project() + + message = str(exc_info.value) + assert "worker" in message + assert str(tmp_path) in message + assert str(missing_file) in message + assert "pyproject.toml" in message + + +def test_python_project_uv_only_launch_material(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'worker'\n") + prepared_python = tmp_path / ".venv" / "bin" / "python" + prepared_python.parent.mkdir(parents=True) + prepared_python.touch() + env = PythonProjectRuntimeEnvironment(name="worker", project=tmp_path) + + material = env.resolve_python_project() + + assert env.convention() == "uv" + assert material.argv_prefix == ["uv", "run", "--no-sync", "python"] + assert material.cwd == tmp_path.resolve() + assert material.env == {} + assert material.prepared_python == prepared_python + + +def test_python_project_pixi_backed_launch_material(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'worker'\n") + (tmp_path / "pixi.toml").write_text("[workspace]\n") + prepared_python = tmp_path / ".venv" / "bin" / "python" + prepared_python.parent.mkdir(parents=True) + prepared_python.touch() + env = PythonProjectRuntimeEnvironment(name="worker", project=tmp_path) + + material = env.resolve_python_project() + + assert env.convention() == "pixi-backed-uv" + assert material.argv_prefix == ["pixi", "run", "uv", "run", "--no-sync", "python"] + assert material.cwd == tmp_path.resolve() + assert material.env == {} + assert material.prepared_python == prepared_python + + +def test_python_project_requires_prepared_venv_python(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'worker'\n") + env = PythonProjectRuntimeEnvironment(name="worker", project=tmp_path) + missing_executable = tmp_path / ".venv" / "bin" / "python" + + with pytest.raises(MissingPreparedPythonProjectError) as exc_info: + env.resolve_python_project() + + message = str(exc_info.value) + assert "worker" in message + assert str(tmp_path) in message + assert str(missing_executable) in message + assert "uv" in message + assert "dimos runtime prepare --runtime worker" in message + + def test_current_process_python_material() -> None: material = CurrentProcessRuntimeEnvironment().resolve_python() diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 3f94a6be4e..2814632daa 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -38,6 +38,10 @@ from dimos.core.daemon import daemonize, install_signal_handlers from dimos.core.global_config import GlobalConfig, global_config from dimos.core.run_registry import get_most_recent, is_pid_alive, stop_entry +from dimos.core.runtime_environment import ( + MissingPreparedPythonProjectError, + PythonProjectRuntimeEnvironmentError, +) from dimos.mapping.utils.cli.map import main as _map_main from dimos.mapping.utils.cli.pose_fill import main as _map_pose_fill_main from dimos.mapping.utils.cli.rename import main as _map_rename_main @@ -147,6 +151,9 @@ def callback(**kwargs) -> None: # type: ignore[no-untyped-def] main.callback()(create_dynamic_callback()) # type: ignore[no-untyped-call] main.add_typer(go2tool_app, name="go2tool") +runtime_app = typer.Typer(help="Runtime environment commands") +main.add_typer(runtime_app, name="runtime") + def arg_help( config: type[BaseModel], @@ -289,7 +296,19 @@ def run( if cli_config_overrides: kwargs["g"] = cli_config_overrides - coordinator = ModuleCoordinator.build(blueprint, kwargs) + try: + coordinator = ModuleCoordinator.build(blueprint, kwargs) + except MissingPreparedPythonProjectError as exc: + raise typer.BadParameter( + f"Blueprint '{blueprint_name}' uses unprepared Python project runtime " + f"'{exc.runtime_name}' at '{exc.project}'; missing executable " + f"'{exc.missing_executable}'. Prepare it with: " + f"dimos runtime prepare {blueprint_name} --runtime {exc.runtime_name}" + ) from exc + except PythonProjectRuntimeEnvironmentError as exc: + raise typer.BadParameter( + f"Blueprint '{blueprint_name}' uses an invalid Python project runtime: {exc}" + ) from exc if daemon: # Health check before daemonizing — catch early crashes @@ -350,6 +369,43 @@ def run( entry.remove() +@runtime_app.command("prepare") +def runtime_prepare( + ctx: typer.Context, + robot_types: list[str] = typer.Argument(..., help="Blueprints or modules to prepare"), + runtime_name: str | None = typer.Option(None, "--runtime", help="Runtime name to prepare"), + disable: list[str] = typer.Option([], "--disable", help="Module names to disable"), + blueprint_args: list[str] = typer.Option((), "--option", "-o"), + config_path: Path = typer.Option( + CONFIG_DIR / "dimos", "--config", "-c", help="Path to config file" + ), +) -> None: + """Prepare active Python project runtime environments for a blueprint.""" + from dimos.core.coordination.blueprints import autoconnect + from dimos.core.runtime_prepare import RuntimePrepareError, prepare_blueprint_runtimes + from dimos.robot.get_all_blueprints import get_by_name_or_exit, get_module_by_name_or_exit + + cli_config_overrides: dict[str, Any] = ctx.obj + global_config.update(**cli_config_overrides) + + blueprint_name = "-".join(robot_types) + blueprint = autoconnect(*map(get_by_name_or_exit, robot_types)) + + if disable: + disabled_classes = tuple( + get_module_by_name_or_exit(name).blueprints[0].module for name in disable + ) + blueprint = blueprint.disabled_modules(*disabled_classes) + + blueprint_config = blueprint.config() + load_config_args(blueprint_config, blueprint_args, config_path) + + try: + prepare_blueprint_runtimes(blueprint, runtime_name, output=typer.echo) + except RuntimePrepareError as exc: + raise typer.BadParameter(f"Blueprint '{blueprint_name}': {exc}") from exc + + @main.command() def status() -> None: """Show the running DimOS instance.""" diff --git a/dimos/robot/cli/test_dimos.py b/dimos/robot/cli/test_dimos.py index b925d4ebd9..37c3ea129f 100644 --- a/dimos/robot/cli/test_dimos.py +++ b/dimos/robot/cli/test_dimos.py @@ -15,10 +15,26 @@ from typing import Literal import pytest +from typer.testing import CliRunner from dimos.core.coordination.blueprints import autoconnect +import dimos.core.coordination.module_coordinator as module_coordinator from dimos.core.module import Module, ModuleConfig -from dimos.robot.cli.dimos import _normalize_simulation_argv, arg_help +from dimos.core.runtime_environment import ( + MissingPythonProjectFileError, + PythonProjectRuntimeEnvironment, + PythonVenvRuntimeEnvironment, +) +from dimos.core.runtime_prepare import ( + RuntimePrepareCommand, + RuntimePrepareError, + RuntimePreparePlan, + prepare_blueprint_runtimes, + prepare_runtime_plan, + select_runtime_prepare_plan, +) +from dimos.robot.cli.dimos import _normalize_simulation_argv, arg_help, main +import dimos.robot.get_all_blueprints as registry @pytest.mark.parametrize( @@ -144,3 +160,258 @@ class TestModule(Module): " * testmodule.spam: str (default: eggs)", "", ] + + +class RuntimePrepareModuleA(Module): + pass + + +class RuntimePrepareModuleB(Module): + pass + + +class RuntimePrepareModuleC(Module): + pass + + +def _project(tmp_path, name: str, *, pixi: bool = False): + project = tmp_path / name + project.mkdir() + (project / "pyproject.toml").write_text("[project]\nname = 'demo'\nversion = '0.0.1'\n") + if pixi: + (project / "pixi.toml").write_text("[workspace]\nchannels = []\nplatforms = []\n") + return project + + +def test_runtime_prepare_selects_active_project_runtimes_only(tmp_path): + active_project = PythonProjectRuntimeEnvironment("active-project", _project(tmp_path, "active")) + disabled_project = PythonProjectRuntimeEnvironment( + "disabled-project", _project(tmp_path, "disabled") + ) + direct_venv = PythonVenvRuntimeEnvironment("direct", tmp_path / "venv" / "bin" / "python") + blueprint = ( + autoconnect( + RuntimePrepareModuleA.blueprint(), + RuntimePrepareModuleB.blueprint(), + RuntimePrepareModuleC.blueprint(), + ) + .runtime_environments(active_project, disabled_project, direct_venv) + .runtime_placements( + { + RuntimePrepareModuleA: "active-project", + RuntimePrepareModuleB: "disabled-project", + RuntimePrepareModuleC: "direct", + } + ) + .disabled_modules(RuntimePrepareModuleB) + ) + + plan = select_runtime_prepare_plan(blueprint) + + assert [command.argv for command in plan.commands] == [ + ["uv", "venv", "--seed"], + ["uv", "sync"], + ] + assert {command.runtime_name for command in plan.commands} == {"active-project"} + assert plan.no_op_runtime_names == ["direct"] + + +def test_runtime_prepare_runtime_unknown_and_unused_errors(tmp_path): + active_project = PythonProjectRuntimeEnvironment("active-project", _project(tmp_path, "active")) + unused_project = PythonProjectRuntimeEnvironment("unused-project", _project(tmp_path, "unused")) + blueprint = ( + RuntimePrepareModuleA.blueprint() + .runtime_environments(active_project, unused_project) + .runtime_placements({RuntimePrepareModuleA: "active-project"}) + ) + + with pytest.raises(RuntimePrepareError, match="Unknown runtime environment 'missing'"): + select_runtime_prepare_plan(blueprint, "missing") + with pytest.raises(RuntimePrepareError, match="not used by the active blueprint"): + select_runtime_prepare_plan(blueprint, "unused-project") + + +def test_runtime_prepare_active_missing_runtime_is_contextual_error(): + blueprint = RuntimePrepareModuleA.blueprint().runtime_placements( + {RuntimePrepareModuleA: "missing-runtime"} + ) + + with pytest.raises(RuntimePrepareError) as exc_info: + select_runtime_prepare_plan(blueprint) + + message = str(exc_info.value) + assert "missing-runtime" in message + assert "RuntimePrepareModuleA" in message + assert "not registered" in message + + with pytest.raises(RuntimePrepareError) as explicit_exc_info: + select_runtime_prepare_plan(blueprint, "missing-runtime") + + explicit_message = str(explicit_exc_info.value) + assert "missing-runtime" in explicit_message + assert "RuntimePrepareModuleA" in explicit_message + assert "not registered" in explicit_message + + +def test_runtime_prepare_direct_venv_no_op_success(tmp_path): + blueprint = ( + RuntimePrepareModuleA.blueprint() + .runtime_environments(PythonVenvRuntimeEnvironment("direct", tmp_path / "python")) + .runtime_placements({RuntimePrepareModuleA: "direct"}) + ) + output: list[str] = [] + calls: list[object] = [] + + prepare_blueprint_runtimes( + blueprint, "direct", runner=lambda *args, **kwargs: calls.append(args), output=output.append + ) # type: ignore[arg-type] + + assert calls == [] + assert "no prepare step is required" in output[0] + + +def test_runtime_prepare_uv_commands_rerun_every_invocation(tmp_path): + blueprint = ( + RuntimePrepareModuleA.blueprint() + .runtime_environments( + PythonProjectRuntimeEnvironment("project", _project(tmp_path, "project")) + ) + .runtime_placements({RuntimePrepareModuleA: "project"}) + ) + calls: list[tuple[list[str], object]] = [] + + def runner(argv, *, check, cwd): + calls.append((argv, cwd)) + + prepare_blueprint_runtimes(blueprint, runner=runner) # type: ignore[arg-type] + prepare_blueprint_runtimes(blueprint, runner=runner) # type: ignore[arg-type] + + project = tmp_path / "project" + assert calls == [ + (["uv", "venv", "--seed"], project), + (["uv", "sync"], project), + (["uv", "venv", "--seed"], project), + (["uv", "sync"], project), + ] + + +def test_runtime_prepare_pixi_commands(tmp_path): + blueprint = ( + RuntimePrepareModuleA.blueprint() + .runtime_environments( + PythonProjectRuntimeEnvironment("project", _project(tmp_path, "project", pixi=True)) + ) + .runtime_placements({RuntimePrepareModuleA: "project"}) + ) + calls: list[list[str]] = [] + + def runner(argv, *, check, cwd): + calls.append(argv) + + prepare_blueprint_runtimes(blueprint, runner=runner) # type: ignore[arg-type] + + assert calls == [ + ["pixi", "install"], + ["pixi", "run", "uv", "venv", "-p", ".pixi/envs/default/bin/python", "--seed"], + ["pixi", "run", "uv", "sync"], + ] + + +def test_runtime_prepare_command_os_error_is_contextual(tmp_path): + plan = RuntimePreparePlan( + commands=[ + RuntimePrepareCommand( + runtime_name="project", + project=tmp_path, + convention="uv", + argv=[str(tmp_path / "missing-uv")], + ) + ], + no_op_runtime_names=[], + ) + + with pytest.raises(RuntimePrepareError) as exc_info: + prepare_runtime_plan(plan) + + message = str(exc_info.value) + assert "project" in message + assert str(tmp_path) in message + assert "missing-uv" in message + + +def test_run_invalid_project_runtime_error_preserves_pyproject_context(tmp_path, monkeypatch): + project = tmp_path / "project-without-pyproject" + project.mkdir() + + blueprint = RuntimePrepareModuleA.blueprint() + monkeypatch.setattr(registry, "get_by_name_or_exit", lambda name: blueprint) + + def build(_blueprint, _kwargs): + raise MissingPythonProjectFileError( + runtime_name="project-env", + project=project, + missing_file=project / "pyproject.toml", + ) + + monkeypatch.setattr(module_coordinator.ModuleCoordinator, "build", build) + + result = CliRunner().invoke( + main, + ["--viewer", "none", "run", "demo-blueprint", "--config", str(tmp_path / "missing.json")], + env={"COLUMNS": "240"}, + ) + + assert result.exit_code != 0 + assert "demo-blueprint" in result.output + assert "project-env" in result.output + assert "project-without-pyproject" in result.output + assert "pyproject.toml" in result.output + + +def test_runtime_prepare_cli_parse_and_run_like_semantics(tmp_path, monkeypatch): + project = _project(tmp_path, "project") + blueprint = ( + autoconnect(RuntimePrepareModuleA.blueprint(), RuntimePrepareModuleB.blueprint()) + .runtime_environments(PythonProjectRuntimeEnvironment("project", project)) + .runtime_placements({RuntimePrepareModuleA: "project", RuntimePrepareModuleB: "project"}) + ) + module_b_blueprint = RuntimePrepareModuleB.blueprint() + calls: list[list[str]] = [] + + import dimos.robot.get_all_blueprints as registry + + monkeypatch.setattr(registry, "get_by_name_or_exit", lambda name: blueprint) + monkeypatch.setattr(registry, "get_module_by_name_or_exit", lambda name: module_b_blueprint) + + def runner(argv, *, check, cwd): + calls.append(argv) + + monkeypatch.setattr("dimos.core.runtime_prepare.subprocess.run", runner) + result = CliRunner().invoke( + main, + [ + "--replay", + "runtime", + "prepare", + "demo-blueprint", + "--runtime", + "project", + "--disable", + RuntimePrepareModuleB.name, + "-o", + "runtimepreparemodulea.frame_id=map", + "--config", + str(tmp_path / "missing.json"), + ], + ) + + assert result.exit_code == 0, result.output + assert calls == [["uv", "venv", "--seed"], ["uv", "sync"]] + assert "Preparing runtime 'project'" in result.output + + +def test_runtime_prepare_cli_command_exists(): + result = CliRunner().invoke(main, ["runtime", "prepare", "--help"]) + + assert result.exit_code == 0, result.output + assert "--runtime" in result.output diff --git a/docs/usage/README.md b/docs/usage/README.md index 3f90aa7bad..99d0918d04 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -7,7 +7,7 @@ This page explains general concepts. - [Modules](/docs/usage/modules.md): The primary units of deployment in DimOS, modules run in parallel and are python classes. - [Streams](/docs/usage/sensor_streams/README.md): How modules communicate, a Pub / Sub system. - [Blueprints](/docs/usage/blueprints.md): a way to group modules together and define their connections to each other. -- [Runtime environments](/docs/usage/runtime_environments.md): run selected Python modules in named venv workers, or resolve native executable settings from named environments. +- [Runtime environments](/docs/usage/runtime_environments.md): run selected Python modules in named venv or project workers, or resolve native executable settings from named environments. - [VGN MuJoCo Grasp Demo](/docs/usage/vgn_mujoco_grasp_demo.md): opt-in xArm7 TSDF reconstruction and grasp candidate visualization demo. - [RPC](/docs/usage/blueprints.md#calling-the-methods-of-other-modules): how one module can call a method on another module (arguments get serialized to JSON-like binary data). - [Skills](/docs/usage/blueprints.md#defining-skills): An RPC function, except it can be called by an AI agent (a tool for an AI). diff --git a/docs/usage/runtime_environments.md b/docs/usage/runtime_environments.md index 35590e7631..2bf1ba7d29 100644 --- a/docs/usage/runtime_environments.md +++ b/docs/usage/runtime_environments.md @@ -2,7 +2,7 @@ Runtime environments let a blueprint choose how selected processes launch without making that choice part of the module class. -Use them when one module needs a different Python environment, or when a native module should get its executable settings from a named environment. +Use them when one module needs a different Python environment, when a Python project owns its own worker environment, or when a native module should get its executable settings from a named environment. ## Python venv workers @@ -59,6 +59,79 @@ class VenvOnlyModel(Module): The demo package at `packages/dimos-demo-worker-module/` follows this pattern. Its publisher imports a runtime helper only inside a worker-side RPC. The test `dimos/core/test_venv_module_demo.py` proves the coordinator can import and build the blueprint, while the placed worker runs the module through the named runtime environment. +## Python project workers + +Use `PythonProjectRuntimeEnvironment(name, project)` when the worker module lives in a separate Python project that should own project-local state. + +```python skip +from pathlib import Path + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment + +runtime = PythonProjectRuntimeEnvironment( + name="grasping", + project=Path("packages/my-grasping-worker"), +) + +blueprint = ( + autoconnect(GraspingModule.blueprint(), ConsumerModule.blueprint()) + .runtime_environments(runtime) + .runtime_placements({GraspingModule: "grasping"}) +) +``` + +The project directory must contain `pyproject.toml`. DimOS uses these conventions only: + +- `.venv/` contains the uv-managed worker Python environment. +- `.pixi/` is optional Pixi state. If `pixi.toml` exists, DimOS treats the project as Pixi-backed. + +Prepare project runtimes explicitly before running a blueprint: + +```bash +dimos runtime prepare +dimos runtime prepare --runtime grasping +``` + +Preparation is blueprint-scoped and acts only on runtimes used by active module placements. Direct `PythonVenvRuntimeEnvironment` entries are externally managed no-ops. + +`dimos run` is non-mutating. It does not install, sync, or update environments. If `.venv/bin/python` is missing, the run fails fast with a diagnostic telling you which `dimos runtime prepare` command to run. This is an existence-only staleness check: after changing `pyproject.toml`, `uv.lock`, `pixi.toml`, or `pixi.lock`, rerun `dimos runtime prepare` yourself. + +Prepare commands run from the project directory on every invocation: + +```bash +# uv-only project +uv venv --seed +uv sync + +# Pixi-backed project +pixi install +pixi run uv venv -p .pixi/envs/default/bin/python --seed +pixi run uv sync +``` + +Worker launch also stays non-mutating: + +- uv-only: `uv run --no-sync python -m dimos.core.coordination.venv_worker_entrypoint ...` +- Pixi-backed: `pixi run uv run --no-sync python -m dimos.core.coordination.venv_worker_entrypoint ...` + +### GPD worker demo + +`packages/dimos-gpd-worker-demo/` is an import-safe project-runtime demo. It pins `gpd @ git+https://github.com/TomCC7/gpd.git@c088d8ae2f7965b067e9a12b3c0dacdbe9da924a`, includes optional Pixi native build dependencies, and exposes `GpdImportProbe.import_gpd_core()`. The RPC lazily imports `gpd.core` inside the worker runtime and returns `gpd import ok: ...` on success. + +Prepare the demo project when you want the optional integration to run: + +```bash +cd packages/dimos-gpd-worker-demo +pixi install +pixi run uv venv -p .pixi/envs/default/bin/python --seed +pixi run uv sync +cd ../.. +uv run pytest dimos/core/test_gpd_worker_demo.py -q +``` + +The same blueprint pattern can be prepared through `dimos runtime prepare --runtime dimos-gpd-worker-demo` if you expose the demo blueprint in a registry entry. Without a prepared `.venv`, the Pixi/GPD integration tests skip; import-safety and placement tests still run. + ## Packaging convention Place venv-deployable modules in their own Python package when they have a dependency closure that should not be installed in the coordinator environment. @@ -112,9 +185,9 @@ Precedence is deterministic: Legacy native configs without `runtime_environment` continue to work. -## Phase 1 limits +## Current limits - Venv workers run on the same machine as the coordinator. - The venv must contain compatible DimOS worker runtime code. In phase 1, this usually means the venv can import the same source checkout or an equivalent installed `dimos` package. -- DimOS does not create, install, or synchronize venvs for you yet. Prepare the environment before running the blueprint. +- Direct `PythonVenvRuntimeEnvironment` entries are not created, installed, or synchronized by DimOS. Prepare those environments outside DimOS before running the blueprint. - There is no remote deployment agent yet. Runtime environments only select local process launch material. diff --git a/openspec/changes/python-project-runtime-environments/.openspec.yaml b/openspec/changes/python-project-runtime-environments/.openspec.yaml new file mode 100644 index 0000000000..f9be753a1e --- /dev/null +++ b/openspec/changes/python-project-runtime-environments/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-27 diff --git a/openspec/changes/python-project-runtime-environments/design.md b/openspec/changes/python-project-runtime-environments/design.md new file mode 100644 index 0000000000..ab84602fb9 --- /dev/null +++ b/openspec/changes/python-project-runtime-environments/design.md @@ -0,0 +1,108 @@ +## Context + +DimOS now supports named Python runtime environments for venv module workers, but the existing backend expects a prepared Python executable path. That is enough for hand-built venvs, but it does not describe how package-local worker runtimes are prepared or how native/C++ dependencies enter the environment. + +The target workflow is a package-local Python project. The package owns `pyproject.toml` for Python dependencies and may also own `pixi.toml` for native, C++, ROS, compiler, and toolchain dependencies. Pixi is optional. uv is required for the first slice. Runtime names remain scoped to the loaded blueprint configuration rather than globally addressable from the CLI. + +## Goals / Non-Goals + +**Goals:** +- Add a convention-only `PythonProjectRuntimeEnvironment(name, project)` model. +- Add `dimos runtime prepare [--runtime ]` that loads the blueprint, finds active placements, and prepares only the used project runtimes. +- Keep `dimos run` non-mutating and fail fast when a project runtime is not prepared. +- Support uv-only and Pixi-backed uv projects. +- Launch project-runtime workers through non-mutating toolchain commands. +- Add a real GPD demo package that proves a Python binding with native build requirements can be prepared and imported in a worker runtime. + +**Non-Goals:** +- No Pixi-only runtime in the first slice; `pyproject.toml` remains required. +- No global `dimos runtime prepare ` command. +- No lockfile hash or timestamp staleness detection in the first slice. +- No automatic prepare during `dimos run`. +- No manual manifest path overrides; the API is convention-only. +- No attempt to run grasp detection in the GPD demo; import success is enough to validate environment preparation. + +## Decisions + +### Blueprint-scoped preparation + +`dimos runtime prepare [run-like flags] [--runtime ]` will evaluate the blueprint using the same configuration path as `dimos run`. Runtime names are resolved only after the blueprint's `RuntimeEnvironmentRegistry` exists. By default, prepare targets only runtime environments referenced by active module placements. If `--runtime` is supplied, the name must refer to an active placement runtime in that blueprint configuration. + +Alternative considered: `dimos runtime prepare `. This was rejected because runtime names are not global and may only exist for a particular blueprint session. + +### Convention-only Python project runtime model + +The new model should be minimal: + +```python +PythonProjectRuntimeEnvironment( + name="roboplan-worker", + project=Path("packages/roboplan-worker"), +) +``` + +The project directory conventions are: +- `pyproject.toml` is required. +- `pixi.toml` is optional. +- `.venv/` is the project-local uv environment. +- `.pixi/` is the project-local Pixi state when Pixi is used. + +Alternative considered: explicit fields for `pyproject`, `pixi_manifest`, `venv`, and Pixi environment name. This was rejected for the first slice because the desired user experience is convention-driven and inspectable, not a per-tool configuration matrix. + +### Prepare always syncs, run only verifies existence + +Preparation is the only mutating phase. For uv-only projects it runs: + +```bash +uv venv --seed +uv sync +``` + +For Pixi-backed uv projects it runs: + +```bash +pixi install +pixi run uv venv -p .pixi/envs/default/bin/python --seed +pixi run uv sync +``` + +`dimos run` only verifies that the prepared runtime exists, starting with `.venv/bin/python`, and produces a prescriptive error if it is missing. It does not compare lockfile hashes or timestamps. Users rerun prepare when manifests change. + +### Toolchain-mediated worker launch + +Project-runtime workers launch through the project toolchain command rather than by reconstructing activation variables manually. For uv-only projects: + +```bash +uv run --no-sync python -m dimos.core.coordination.venv_worker_entrypoint ... +``` + +For Pixi-backed uv projects: + +```bash +pixi run uv run --no-sync python -m dimos.core.coordination.venv_worker_entrypoint ... +``` + +`--no-sync` is required because `dimos run` must not mutate the environment. Existing `PythonVenvRuntimeEnvironment` continues to launch a direct Python executable. + +Alternative considered: capture Pixi activation environment and launch `.venv/bin/python` directly. This was rejected because the toolchain command better matches the user's Pixi+uv workflow and avoids hand-built activation semantics. + +### Keep current shutdown semantics first + +The current worker lifecycle sends a DimOS `ShutdownRequest`, waits for the worker to exit, then falls back to terminating the launched process handle. The first implementation should keep that behavior for toolchain-mediated workers. Process-group termination should be added only if a shutdown regression proves wrapper commands leave orphaned child processes. + +### GPD demo as the native dependency proof + +Add `packages/dimos-gpd-worker-demo/` with a `pyproject.toml` that depends on a pinned `TomCC7/gpd` commit: + +```text +c088d8ae2f7965b067e9a12b3c0dacdbe9da924a +``` + +The package should include a `pixi.toml` for native build dependencies and a dummy DimOS Module whose RPC lazily imports `gpd.core`. The automated Pixi/GPD integration test should skip when Pixi is unavailable; uv-only and command construction tests should run normally. + +## Risks / Trade-offs + +- **Stale environments may pass `dimos run` checks** → Users must rerun `dimos runtime prepare` after changing `pyproject.toml`, `uv.lock`, `pixi.toml`, or `pixi.lock`. Docs and errors should state this clearly. +- **Pixi/GPD builds may be slow or unavailable in some CI contexts** → Gate Pixi integration tests with skip-if-missing Pixi and keep deterministic unit tests for command construction. +- **Toolchain wrapper shutdown could leave child processes** → Preserve current shutdown behavior first and add a process cleanup regression; escalate to process groups only if the regression fails. +- **GPD native dependencies may require package-name iteration** → Treat the GPD demo as an integration spike task with a pinned commit and documented Pixi dependencies. diff --git a/openspec/changes/python-project-runtime-environments/proposal.md b/openspec/changes/python-project-runtime-environments/proposal.md new file mode 100644 index 0000000000..290e1c349c --- /dev/null +++ b/openspec/changes/python-project-runtime-environments/proposal.md @@ -0,0 +1,29 @@ +## Why + +Venv module workers can already run from a named Python executable, but users still need to prepare those environments by hand and duplicate tool-specific setup outside the blueprint workflow. Python worker packages that need C++ or ROS dependencies need a convention that combines uv Python packaging with optional Pixi native dependency preparation while keeping `dimos run` predictable. + +## What Changes + +- Add a convention-driven Python project runtime environment rooted at a package directory with a required `pyproject.toml` and optional `pixi.toml`. +- Add an explicit blueprint-scoped runtime preparation command that prepares only runtime environments used by active module placements. +- Keep `dimos run` non-mutating: it fails fast when a project runtime is not prepared instead of running uv or Pixi sync/install. +- Launch convention-based project workers through the project toolchain with non-mutating uv flags, such as `pixi run uv run --no-sync python ...` when Pixi is present. +- Preserve existing direct-interpreter `PythonVenvRuntimeEnvironment` behavior for already-prepared interpreter paths. +- Add a GPD worker demo package that uses a pinned `TomCC7/gpd` git dependency and Pixi native build environment, with a dummy Module that verifies `gpd.core` imports in the worker runtime. + +## Capabilities + +### New Capabilities +- `runtime-environment-preparation`: Blueprint-scoped explicit preparation of active runtime environments before `dimos run`. + +### Modified Capabilities +- `runtime-environment-registry`: Add a convention-driven Python project runtime environment backend and non-mutating project-runtime resolution. +- `venv-module-placement`: Allow placed Modules to launch from project-runtime toolchain commands while preserving worker lifecycle semantics. +- `venv-module-packaging`: Extend packaging/demo coverage to Python packages with native/C++ build dependencies managed through optional Pixi plus uv. + +## Impact + +- Affected APIs: runtime environment Python models, blueprint runtime environment resolution, and DimOS CLI runtime subcommands. +- Affected worker launch code: project-runtime worker launcher command construction, startup diagnostics, and shutdown regression coverage. +- Affected packaging/docs: add docs for project-local `.venv`/`.pixi` conventions and a GPD demo package under `packages/`. +- New optional tool dependency for full integration tests: Pixi. Tests must skip Pixi/GPD integration when Pixi is unavailable. diff --git a/openspec/changes/python-project-runtime-environments/specs/runtime-environment-preparation/spec.md b/openspec/changes/python-project-runtime-environments/specs/runtime-environment-preparation/spec.md new file mode 100644 index 0000000000..15fd855bd4 --- /dev/null +++ b/openspec/changes/python-project-runtime-environments/specs/runtime-environment-preparation/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Runtime preparation is blueprint-scoped +The system SHALL provide an explicit runtime preparation command that resolves runtime environment names by loading a blueprint configuration before preparing any environment. + +#### Scenario: Prepare active placement runtimes for blueprint +- **WHEN** the user runs `dimos runtime prepare ` with valid run-like configuration flags +- **THEN** the system loads the blueprint, identifies active module placements, and prepares only the runtime environments referenced by those active placements + +#### Scenario: Runtime name is resolved within blueprint +- **WHEN** the user runs `dimos runtime prepare --runtime ` +- **THEN** the system resolves `` against the runtime environment registry produced by that blueprint configuration + +#### Scenario: Runtime exists but is unused +- **WHEN** the requested runtime name exists in the blueprint registry but is not referenced by an active module placement +- **THEN** the preparation command fails with an error explaining that the runtime is not used by the active blueprint configuration + +### Requirement: Runtime preparation is explicit and repeatable +The system SHALL keep runtime environment preparation separate from blueprint execution and SHALL make the preparation command safe to rerun when project manifests change. + +#### Scenario: Prepare uv-only project runtime +- **WHEN** an active runtime placement references a Python project runtime with `pyproject.toml` and no `pixi.toml` +- **THEN** runtime preparation runs `uv venv --seed` and `uv sync` from the project directory + +#### Scenario: Prepare Pixi-backed uv project runtime +- **WHEN** an active runtime placement references a Python project runtime with both `pyproject.toml` and `pixi.toml` +- **THEN** runtime preparation runs `pixi install`, `pixi run uv venv -p .pixi/envs/default/bin/python --seed`, and `pixi run uv sync` from the project directory + +#### Scenario: Prepare reruns sync commands +- **WHEN** a prepared project runtime already has a `.venv` +- **THEN** runtime preparation still runs the applicable Pixi and uv sync commands instead of skipping solely because the `.venv` exists + +### Requirement: Blueprint run fails fast for unprepared project runtimes +The system SHALL NOT install, sync, or otherwise mutate Python project runtime environments during normal blueprint run or build. + +#### Scenario: Project runtime is missing venv Python +- **WHEN** `dimos run ` evaluates an active placement using a Python project runtime whose `.venv/bin/python` is missing +- **THEN** blueprint build fails before worker launch with a message naming the runtime, blueprint, project path, missing executable, and the `dimos runtime prepare` command to run + +#### Scenario: Project runtime exists +- **WHEN** `dimos run ` evaluates an active placement using a Python project runtime whose `.venv/bin/python` exists +- **THEN** the system attempts worker launch without running Pixi or uv sync commands diff --git a/openspec/changes/python-project-runtime-environments/specs/runtime-environment-registry/spec.md b/openspec/changes/python-project-runtime-environments/specs/runtime-environment-registry/spec.md new file mode 100644 index 0000000000..44c41934d0 --- /dev/null +++ b/openspec/changes/python-project-runtime-environments/specs/runtime-environment-registry/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Python project runtime environments resolve by convention +The system SHALL support a named Python project runtime environment that resolves Python worker launch material from a project directory using DimOS-defined conventions. + +#### Scenario: Register Python project runtime environment +- **WHEN** a blueprint registers `PythonProjectRuntimeEnvironment(name="worker", project=Path("packages/worker"))` +- **THEN** the runtime environment registry stores the environment under `worker` without requiring explicit manifest, venv, or Pixi path fields + +#### Scenario: pyproject is required +- **WHEN** a Python project runtime environment is resolved for preparation or worker launch and the project directory does not contain `pyproject.toml` +- **THEN** resolution fails with an actionable error explaining that first-slice Python project runtimes require a uv project + +#### Scenario: Pixi is optional +- **WHEN** a Python project runtime environment project contains `pyproject.toml` but no `pixi.toml` +- **THEN** the system treats it as a uv-only Python project runtime + +#### Scenario: Pixi-backed uv project is detected +- **WHEN** a Python project runtime environment project contains both `pyproject.toml` and `pixi.toml` +- **THEN** the system treats it as a Pixi-backed uv runtime where Pixi supplies the Python used to create the project-local uv `.venv` + +### Requirement: Python project runtime launch is non-mutating +The system SHALL resolve Python project runtime launch commands without performing environment preparation during worker deployment. + +#### Scenario: Resolve uv-only launch command +- **WHEN** a uv-only Python project runtime is used for worker launch +- **THEN** the worker launcher receives a non-mutating command equivalent to `uv run --no-sync python -m dimos.core.coordination.venv_worker_entrypoint ...` + +#### Scenario: Resolve Pixi-backed launch command +- **WHEN** a Pixi-backed uv runtime is used for worker launch +- **THEN** the worker launcher receives a non-mutating command equivalent to `pixi run uv run --no-sync python -m dimos.core.coordination.venv_worker_entrypoint ...` diff --git a/openspec/changes/python-project-runtime-environments/specs/venv-module-packaging/spec.md b/openspec/changes/python-project-runtime-environments/specs/venv-module-packaging/spec.md new file mode 100644 index 0000000000..06415cf63f --- /dev/null +++ b/openspec/changes/python-project-runtime-environments/specs/venv-module-packaging/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Venv Module packages can use Pixi-backed native dependencies +The system SHALL support venv Module packages whose Python dependencies require native or C++ build dependencies supplied by an optional package-local `pixi.toml`. + +#### Scenario: Package declares Python dependency closure with pyproject +- **WHEN** a venv Module package is used as a Python project runtime +- **THEN** its `pyproject.toml` declares the Python package dependencies that uv installs into the project-local `.venv` + +#### Scenario: Package declares native build environment with pixi +- **WHEN** a venv Module package includes `pixi.toml` +- **THEN** Pixi supplies the native build tools and libraries used while uv installs the package dependency closure + +### Requirement: GPD demo proves native Python binding import in worker runtime +The system SHALL include a GPD worker demo package that proves a Python binding with native build requirements can be prepared and imported in a placed venv worker. + +#### Scenario: Demo package depends on pinned GPD source +- **WHEN** the GPD demo package is prepared +- **THEN** uv installs a dependency on `TomCC7/gpd` pinned to commit `c088d8ae2f7965b067e9a12b3c0dacdbe9da924a` + +#### Scenario: Demo Module imports GPD in worker +- **WHEN** the GPD demo blueprint runs with its dummy Module placed into the project runtime +- **THEN** a Module RPC lazily imports `gpd.core` inside the worker process and reports import success through normal DimOS RPC behavior + +#### Scenario: Pixi integration test is optional when Pixi is unavailable +- **WHEN** the automated test environment does not have Pixi installed +- **THEN** Pixi/GPD integration tests are skipped while uv-only and command-construction tests still run diff --git a/openspec/changes/python-project-runtime-environments/specs/venv-module-placement/spec.md b/openspec/changes/python-project-runtime-environments/specs/venv-module-placement/spec.md new file mode 100644 index 0000000000..debf527db4 --- /dev/null +++ b/openspec/changes/python-project-runtime-environments/specs/venv-module-placement/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Project runtime workers preserve worker lifecycle semantics +The system SHALL deploy Modules placed into Python project runtimes through the existing DimOS worker lifecycle and control-channel semantics. + +#### Scenario: Project runtime worker connects to coordinator +- **WHEN** the coordinator launches a worker through a uv-only or Pixi-backed project runtime command +- **THEN** the worker connects to the coordinator's local multiprocessing connection endpoint before receiving deploy requests + +#### Scenario: Project runtime worker shuts down normally +- **WHEN** a project-runtime worker receives the normal DimOS shutdown request +- **THEN** the worker exits through the existing worker shutdown path without requiring a separate shutdown protocol for uv or Pixi wrappers + +#### Scenario: Project runtime worker termination fallback +- **WHEN** a project-runtime worker does not exit after the normal shutdown timeout +- **THEN** DimOS applies the existing worker process-handle termination fallback to the launched process and tests verify whether additional cleanup is necessary + +### Requirement: Project runtime placement remains active-module scoped +The system SHALL keep Python project runtime placement scoped to active module placements in the loaded blueprint configuration. + +#### Scenario: Unused project runtime is not launched +- **WHEN** a blueprint registers a Python project runtime environment that is not referenced by any active module placement +- **THEN** blueprint build does not prepare, launch, or allocate a worker pool for that runtime + +#### Scenario: Same Module can run without project runtime placement +- **WHEN** a Module class appears in a blueprint without project runtime placement after previously being used with a project runtime +- **THEN** the coordinator deploys it through the default Python worker pool diff --git a/openspec/changes/python-project-runtime-environments/tasks.md b/openspec/changes/python-project-runtime-environments/tasks.md new file mode 100644 index 0000000000..b481e7f9da --- /dev/null +++ b/openspec/changes/python-project-runtime-environments/tasks.md @@ -0,0 +1,41 @@ +## 1. Runtime model and resolution + +- [x] 1.1 Add `PythonProjectRuntimeEnvironment(name, project)` to the runtime environment model. +- [x] 1.2 Detect uv-only versus Pixi-backed uv project conventions from `pyproject.toml` and optional `pixi.toml`. +- [x] 1.3 Validate required project files and emit actionable errors for missing `pyproject.toml` or missing prepared `.venv/bin/python`. +- [x] 1.4 Resolve non-mutating worker launch commands for uv-only and Pixi-backed project runtimes. +- [x] 1.5 Add unit tests for project runtime detection, validation, and launch command construction. + +## 2. Runtime preparation CLI + +- [x] 2.1 Add `dimos runtime prepare [--runtime ]` CLI entrypoint using blueprint loading semantics compatible with `dimos run`. +- [x] 2.2 Select only active placement runtime environments by default after evaluating disabled modules and runtime placements. +- [x] 2.3 Reject `--runtime ` when the name is unknown or not used by an active placement in the loaded blueprint. +- [x] 2.4 Implement uv-only prepare commands: `uv venv --seed` and `uv sync` from the project directory. +- [x] 2.5 Implement Pixi-backed prepare commands: `pixi install`, `pixi run uv venv -p .pixi/envs/default/bin/python --seed`, and `pixi run uv sync` from the project directory. +- [x] 2.6 Add CLI tests for active-runtime selection, command sequencing, copy/paste diagnostics, and repeated prepare behavior. + +## 3. Worker launch integration + +- [x] 3.1 Add a project-runtime worker launcher or extend the existing launcher abstraction to run command arrays instead of only direct Python executable paths. +- [x] 3.2 Wire `PythonProjectRuntimeEnvironment` placements through `WorkerManagerPython` without changing existing `PythonVenvRuntimeEnvironment` behavior. +- [x] 3.3 Ensure `dimos run` fails fast without syncing when a project runtime is missing `.venv/bin/python`. +- [x] 3.4 Preserve current shutdown semantics and add a regression proving project-runtime workers exit through the normal worker lifecycle. +- [x] 3.5 Add fallback termination coverage for a project-runtime worker that does not respond to normal shutdown. + +## 4. GPD demo package + +- [x] 4.1 Add `packages/dimos-gpd-worker-demo/` as an import-safe worker package with `pyproject.toml`, optional `pixi.toml`, and source package files. +- [x] 4.2 Pin the demo package dependency on `TomCC7/gpd` commit `c088d8ae2f7965b067e9a12b3c0dacdbe9da924a`. +- [x] 4.3 Add a dummy DimOS Module whose RPC lazily imports `gpd.core` inside the worker runtime and returns import success. +- [x] 4.4 Add a demo blueprint that registers `PythonProjectRuntimeEnvironment(name, project)` and places the dummy Module into it. +- [x] 4.5 Add uv-only always-on tests plus Pixi/GPD integration tests that skip when Pixi is unavailable. + +## 5. Documentation and validation + +- [x] 5.1 Update runtime environment docs with project-local `.venv`/`.pixi` conventions, explicit prepare, and non-mutating run semantics. +- [x] 5.2 Document the Pixi-backed uv command sequence and explain that users rerun prepare after manifest changes. +- [x] 5.3 Document the GPD demo command and expected import-success result. +- [x] 5.4 Run focused runtime environment, worker launcher, blueprint placement, CLI, and demo tests. +- [x] 5.5 Run `openspec validate python-project-runtime-environments --type change --strict --no-interactive` and fix validation issues. +- [x] 5.6 Final-review blocker: preserve capability-based Python runtime placement by routing non-project runtimes through `resolve_python()`, including the default `current` runtime and custom Python-capable runtime environments. diff --git a/packages/dimos-gpd-worker-demo/pixi.lock b/packages/dimos-gpd-worker-demo/pixi.lock new file mode 100644 index 0000000000..fc9a81c40a --- /dev/null +++ b/packages/dimos-gpd-worker-demo/pixi.lock @@ -0,0 +1,4704 @@ +version: 7 +platforms: +- name: linux-64 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aiohttp-3.14.1-py312h5d8c7f2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.14.1-pl5321h039972f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils-2.45.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.45.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - 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/linux-64/cairo-1.18.4-he90730b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cli11-2.6.2-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cmake-4.3.4-hc85cc9f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/compilers-1.11.0-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.3.0-he8ccf15_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py312h0a2e395_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/eigen-5.0.1-hc65338a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/eigen-abi-5.0.1.80-hf414acd_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-8.1.2-gpl_h1bf8424_901.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/flann-1.9.2-he1b7b50_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fmt-12.1.0-hff5e90c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.1-h27c8c51_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.63.0-py312h8a5da7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fortran-compiler-1.11.0-h9bea470_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/frozenlist-1.8.0-py312h447239a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.3.0-h0dff253_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.3.0-h235f0fe_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.3.0-h50e9bb6_27.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran-14.3.0-he448592_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-14.3.0-h1a219da_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_linux-64-14.3.0-h5ce6d8a_27.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gl2ps-1.4.2-h36e74d4_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glew-2.3.0-h71661d4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glslang-16.3.0-h96af755_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-14.3.0-he448592_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-h2185e75_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-hd240bd5_27.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.2.1-h6083320_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.2.2-hde8ca8f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-gmmlib-22.10.0-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-media-driver-26.1.6-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/jsoncpp-1.9.7-h171cf75_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py312h0a2e395_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/level-zero-1.29.0-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.4-h96ad9f0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.4.2-hf998032_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-8_h4a7cf45_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-1.90.0-hd24cca6_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-devel-1.90.0-hfcd1e18_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-headers-1.90.0-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.78-hd0affe5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-8_h0358290_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.21.0-hcf29cc6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdovi-3.3.2-ha23c83e_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.127-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.2-h0d30a3d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.13.0-default_he001693_1000.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.2-h174a0a3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-8_h6ae95b6_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.8-hf7376ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-devel-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.10.0-nompi_hb6f1874_104.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopencv-5.0.0-headless_hf42b598_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2026.2.1-h7e124b3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2026.2.1-h7e124b3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2026.2.1-hd41364c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-npu-plugin-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-ir-frontend-2026.2.1-hd41364c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2026.2.1-h607c73d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2026.2.1-h607c73d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2026.2.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2026.2.1-h21c0c73_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2026.2.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libplacebo-7.360.1-h9eeb4b2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpq-18.4-hd5a49e9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-7.35.1-h3a69515_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libraqm-0.10.5-h75b3fb1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.3-h4c96295_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.3.0-h8f1669f_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-h084b8d7_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtheora-1.1.1-h4ab18f5_1006.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-h084b8d7_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libunwind-1.8.3-h65a8314_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liburing-2.14-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libva-2.23.0-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpl-2.16.0-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpx-1.15.2-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.341.0-h5279c79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbfile-1.2.0-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.0-py312h1c5ec97_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mesalib-26.0.3-h8cca3c9_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.32.9-hc50e24c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/msgpack-python-1.2.1-py312h0a2e395_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/multidict-6.7.1-py312h8a5da7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nanoflann-1.6.1-hff21bea_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.0-py312h33ff503_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.4-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/opencv-5.0.0-headless_h47a1348_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.4.13-hf734b31_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-h65dd3cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.30.1-h8d634f6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hda50119_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcl-1.15.1-h1259f1f_14.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/pillow-12.2.0-py312h50c33e8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/proj-9.8.1-he0df7b0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/propcache-0.5.2-py312h8a5da7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/py-opencv-5.0.0-headless_h75b7ce4_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.11.1-pl5321h16c4a6b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rhash-1.4.6-hb9d3cd8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.10-hdeec2a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2026.2-h718be3e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.2-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.3-hbc0de68_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.0.1-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-hab88423_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-devel-2023.0.0-hab88423_2.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/linux-64/unicodedata2-17.0.1-py312h4c3975b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/utfcpp-4.09-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.11.25-h26efc2c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/viskores-1.1.1-cpu_hc82bd48_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-9.6.2-py312h244374b_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-base-9.6.2-py312h5e1aeb2_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-io-ffmpeg-9.6.2-py312h14f7233_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxshmfence-1.3.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/yarl-1.24.2-py312h8a5da7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zfp-1.0.1-h909a3a2_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.3.0-hf649bbc_119.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.3.0-h9f08a49_119.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_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/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.49-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wslink-2.5.7-pyhd8ed1ab_0.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aiohttp-3.14.1-py312h5d8c7f2_0.conda + sha256: 7e03efaf3671cfca5d3195dc18aa3ca6bc182e0e34d30a95adb4f64f71d8f10c + md5: 10e4a93c73bfe09c5909cb1d41a1e40e + depends: + - __glibc >=2.17,<3.0.a0 + - aiohappyeyeballs >=2.5.0 + - aiosignal >=1.4.0 + - attrs >=17.3.0 + - frozenlist >=1.1.1 + - libgcc >=14 + - multidict >=4.5,<7.0 + - propcache >=0.2.0 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - typing_extensions >=4.4 + - yarl >=1.17.0,<2.0 + license: MIT AND Apache-2.0 + license_family: Apache + run_exports: {} + size: 1078273 + timestamp: 1780913823270 +- conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-hb03c661_0.conda + sha256: cf93ca0f1f107e95a35969a4622684e08fcb8cf37f8cf4a1e9e424828386c921 + md5: 8904e09bda369377b3dd07e2ac828c5d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-or-later + license_family: LGPL + run_exports: + weak: + - alsa-lib >=1.2.16.1,<1.3.0a0 + size: 592377 + timestamp: 1781521980743 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.14.1-pl5321h039972f_1.conda + sha256: b1d972a9b949a88babee681437535550b3ca5dbca6a23a40dffeb7900fec19fd + md5: 5a78a69eb3b50f24b379e9d2a93163ae + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - aom >=3.14.1,<3.15.0a0 + size: 3103347 + timestamp: 1780752473089 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils-2.45.1-default_h4852527_102.conda + sha256: 3c7c5580c1720206f28b7fa3d60d17986b3f32465e63009c14c9ae1ea64f926c + md5: 212fe5f1067445544c99dc1c847d032c + depends: + - binutils_impl_linux-64 >=2.45.1,<2.45.2.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 35436 + timestamp: 1774197482571 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda + sha256: 0a7d405064f53b9d91d92515f1460f7906ee5e8523f3cd8973430e81219f4917 + md5: 8165352fdce2d2025bf884dc0ee85700 + depends: + - ld_impl_linux-64 2.45.1 default_hbd61a6d_102 + - sysroot_linux-64 + - zstd >=1.5.7,<1.6.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 3661455 + timestamp: 1774197460085 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.45.1-default_h4852527_102.conda + sha256: 78a58d523d072b7f8e591b8f8572822e044b31764ed7e8d170392e7bc6d58339 + md5: 2a307a17309d358c9b42afdd3199ddcc + depends: + - binutils_impl_linux-64 2.45.1 default_hfdba357_102 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 36304 + timestamp: 1774197485247 +- conda: https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda + sha256: e7af5d1183b06a206192ff440e08db1c4e8b2ca1f8376ee45fb2f3a85d4ee45d + md5: 2c2fae981fd2afd00812c92ac47d023d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.1,<1.3.0a0 + - zstd >=1.5.6,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - blosc >=1.21.6,<2.0a0 + size: 48427 + timestamp: 1733513201413 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda + sha256: e511644d691f05eb12ebe1e971fd6dc3ae55a4df5c253b4e1788b789bdf2dfa6 + md5: 8ccf913aaba749a5496c17629d859ed1 + depends: + - __glibc >=2.17,<3.0.a0 + - brotli-bin 1.2.0 hb03c661_1 + - libbrotlidec 1.2.0 hb03c661_1 + - libbrotlienc 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + size: 20103 + timestamp: 1764017231353 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda + sha256: 64b137f30b83b1dd61db6c946ae7511657eead59fdf74e84ef0ded219605aa94 + md5: af39b9a8711d4a8d437b52c1d78eb6a1 + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlidec 1.2.0 hb03c661_1 + - libbrotlienc 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: {} + size: 21021 + timestamp: 1764017221344 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 + md5: d2ffd7602c02f2b316fd921d39876885 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 260182 + timestamp: 1771350215188 +- conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda + sha256: cc9accf72fa028d31c2a038460787751127317dcfa991f8d1f1babf216bb454e + md5: 920bb03579f15389b9e512095ad995b7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - c-ares >=1.34.6,<2.0a0 + size: 207882 + timestamp: 1765214722852 +- conda: https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda + sha256: 8e7a40f16400d7839c82581410aa05c1f8324a693c9d50079f8c50dc9fb241f0 + md5: abd85120de1187b0d1ec305c2173c71b + depends: + - binutils + - gcc + - gcc_linux-64 14.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6693 + timestamp: 1753098721814 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda + sha256: 06525fa0c4e4f56e771a3b986d0fdf0f0fc5a3270830ee47e127a5105bde1b9a + md5: bb6c4808bfa69d6f7f6b07e5846ced37 + depends: + - __glibc >=2.17,<3.0.a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - icu >=78.1,<79.0a0 + - libexpat >=2.7.3,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libgcc >=14 + - libglib >=2.86.3,<3.0a0 + - libpng >=1.6.53,<1.7.0a0 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pixman >=0.46.4,<1.0a0 + - xorg-libice >=1.1.2,<2.0a0 + - xorg-libsm >=1.2.6,<2.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + license: LGPL-2.1-only or MPL-1.1 + run_exports: + weak: + - cairo >=1.18.4,<2.0a0 + size: 989514 + timestamp: 1766415934926 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cli11-2.6.2-h54a6638_0.conda + sha256: 1d635e8963e094d95d35148df4b46e495f93bb0750ad5069f4e0e6bbb47ac3bf + md5: 83dae3dfadcfec9b37a9fbff6f7f7378 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 99051 + timestamp: 1772207728613 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cmake-4.3.4-hc85cc9f_0.conda + sha256: a3369cbf4c8d77a3398c3c2bb1e7d72af26ac9c655e4753964bdb744bf1e5e8c + md5: aa9174a98942599973baf4c5639e13b5 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libcurl >=8.20.0,<9.0a0 + - libexpat >=2.8.1,<3.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libstdcxx >=14 + - libuv >=1.52.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - rhash >=1.4.6,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 23168153 + timestamp: 1781778936323 +- conda: https://conda.anaconda.org/conda-forge/linux-64/compilers-1.11.0-ha770c72_0.conda + sha256: 5709f2cbfeb8690317ba702611bdf711a502b417fecda6ad3313e501f6f8bd61 + md5: fdcf2e31dd960ef7c5daa9f2c95eff0e + depends: + - c-compiler 1.11.0 h4d9bdce_0 + - cxx-compiler 1.11.0 hfcd1e18_0 + - fortran-compiler 1.11.0 h9bea470_0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 7482 + timestamp: 1753098722454 +- conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.3.0-he8ccf15_19.conda + sha256: 769357d82d19f59c23888d935d92b67739bdb2acaacaa7bbe7c4fe4ee5346287 + md5: fd57230e9a97b97bf20dd63aeae6fe61 + depends: + - gcc_impl_linux-64 >=14.3.0,<14.3.1.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 31751 + timestamp: 1778268677594 +- conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py312h0a2e395_4.conda + sha256: 62447faf7e8eb691e407688c0b4b7c230de40d5ecf95bf301111b4d05c5be473 + md5: 43c2bc96af3ae5ed9e8a10ded942aa50 + depends: + - numpy >=1.25 + - python + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 320386 + timestamp: 1769155979897 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda + sha256: 3fcc97ae3e89c150401a50a4de58794ffc67b1ed0e1851468fcc376980201e25 + md5: 5da8c935dca9186673987f79cef0b2a5 + depends: + - c-compiler 1.11.0 h4d9bdce_0 + - gxx + - gxx_linux-64 14.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6635 + timestamp: 1753098722177 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda + sha256: 7684da83306bb69686c0506fb09aa7074e1a55ade50c3a879e4e5df6eebb1009 + md5: af491aae930edc096b58466c51c4126c + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=13 + - libntlm >=1.8,<2.0a0 + - libstdcxx >=13 + - libxcrypt >=4.4.36 + - openssl >=3.5.5,<4.0a0 + license: BSD-3-Clause-Attribution + license_family: BSD + run_exports: + weak: + - cyrus-sasl >=2.1.28,<3.0a0 + size: 210103 + timestamp: 1771943128249 +- conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda + sha256: 22053a5842ca8ee1cf8e1a817138cdb5e647eb2c46979f84153f6ad7bde73020 + md5: 418c6ca5929a611cbd69204907a83995 + depends: + - libgcc-ng >=12 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - dav1d >=1.2.1,<1.2.2.0a0 + size: 760229 + timestamp: 1685695754230 +- conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + sha256: 8bb557af1b2b7983cf56292336a1a1853f26555d9c6cecf1e5b2b96838c9da87 + md5: ce96f2f470d39bd96ce03945af92e280 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - libglib >=2.86.2,<3.0a0 + - libexpat >=2.7.3,<3.0a0 + license: AFL-2.1 OR GPL-2.0-or-later + run_exports: + weak: + - dbus >=1.16.2,<2.0a0 + size: 447649 + timestamp: 1764536047944 +- conda: https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda + sha256: 40cdd1b048444d3235069d75f9c8e1f286db567f6278a93b4f024e5642cfaecc + md5: dbe3ec0f120af456b3477743ffd99b74 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - double-conversion >=3.4.0,<3.5.0a0 + size: 71809 + timestamp: 1765193127016 +- conda: https://conda.anaconda.org/conda-forge/linux-64/eigen-5.0.1-hc65338a_0.conda + sha256: f122c5bb618532eb40124f34dc3d467b9142c4a573c206e3e6a51df671345d6a + md5: fcc98d38ae074ee72ee9152e357bcbf2 + license: MPL-2.0 + license_family: MOZILLA + run_exports: {} + size: 1311364 + timestamp: 1773744756289 +- conda: https://conda.anaconda.org/conda-forge/linux-64/eigen-abi-5.0.1.80-hf414acd_0.conda + sha256: acde70d55f7dbbc043d733427fd6f1ec6ca49db7cbabcf7a656dd29a952b8ad8 + md5: a85c1768af7780d67c6446c17848b76f + constrains: + - eigen >=5.0.1,<5.0.2.0a0 + license: MPL-2.0 + license_family: MOZILLA + run_exports: {} + size: 13265 + timestamp: 1773744756289 +- conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.8.1-hecca717_1.conda + sha256: bc1e8177a4ebbbfcda98b6f4a1575f3337e69f0d2f1025a84570533ca27b89eb + md5: e211a78613b9d50a096644b97cede8f7 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat 2.8.1 hecca717_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libexpat >=2.8.1,<3.0a0 + size: 147797 + timestamp: 1781203608158 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-8.1.2-gpl_h1bf8424_901.conda + sha256: 2d56b0d90a1e581e296a41aa0a0443c85f918a94780e779a23005be9128627be + md5: 0c457f1b2384bb0aa984831a79021a66 + depends: + - __glibc >=2.17,<3.0.a0 + - alsa-lib >=1.2.16.1,<1.3.0a0 + - aom >=3.14.1,<3.15.0a0 + - bzip2 >=1.0.8,<2.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - gmp >=6.3.0,<7.0a0 + - harfbuzz >=14.2.1 + - lame >=3.100,<3.101.0a0 + - libass >=0.17.4,<0.17.5.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libjxl >=0.11,<1.0a0 + - liblzma >=5.8.3,<6.0a0 + - libopenvino >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-batch-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-hetero-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-cpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-gpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-npu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-ir-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-onnx-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-paddle-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-pytorch-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-lite-frontend >=2026.2.1,<2026.2.2.0a0 + - libopus >=1.6.1,<2.0a0 + - libplacebo >=7.360.1,<7.361.0a0 + - librsvg >=2.62.3,<3.0a0 + - libstdcxx >=14 + - libva >=2.23.0,<3.0a0 + - libvorbis >=1.3.7,<1.4.0a0 + - libvpl >=2.16.0,<2.17.0a0 + - libvpx >=1.15.2,<1.16.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openh264 >=2.6.0,<2.6.1.0a0 + - openssl >=3.5.7,<4.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - sdl2 >=2.32.56,<3.0a0 + - shaderc >=2026.2,<2026.3.0a0 + - svt-av1 >=4.0.1,<4.0.2.0a0 + - x264 >=1!164.3095,<1!165 + - x265 >=3.5,<3.6.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + constrains: + - __cuda >=12.8 + license: GPL-2.0-or-later + license_family: GPL + run_exports: + weak: + - ffmpeg >=8.1.2,<9.0a0 + size: 13078770 + timestamp: 1782260267617 +- conda: https://conda.anaconda.org/conda-forge/linux-64/flann-1.9.2-he1b7b50_6.conda + sha256: 690c6e8204678d40fff369c96110735a1a6f0b47256359676aa26176a151a4a6 + md5: e5d172b12683fccd78ab3446c9a29707 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libgcc >=14 + - libstdcxx >=14 + - lz4-c >=1.10.0,<1.11.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - flann >=1.9.2,<1.9.3.0a0 + size: 1990499 + timestamp: 1774331944832 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fmt-12.1.0-hff5e90c_0.conda + sha256: d4e92ba7a7b4965341dc0fca57ec72d01d111b53c12d11396473115585a9ead6 + md5: f7d7a4104082b39e3b3473fbd4a38229 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - fmt >=12.1.0,<12.2.0a0 + size: 198107 + timestamp: 1767681153946 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.1-h27c8c51_0.conda + sha256: 2e50bdcebdf70a865b81f2456bbc586386451ec601c60f2b6cd22b8c40a2d384 + md5: e0e050cfa9fa85fe39632ab11cb7f3e0 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libuuid >=2.42.1,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + size: 281880 + timestamp: 1780450077431 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.63.0-py312h8a5da7c_0.conda + sha256: d235ae7075642044ceb3d922ef2a710a82665755ac9bbb7e8dad7daa72bc6d87 + md5: 294fb524171e2a2748cb7fe708aba826 + depends: + - __glibc >=2.17,<3.0.a0 + - brotli + - libgcc >=14 + - munkres + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - unicodedata2 >=15.1.0 + license: MIT + license_family: MIT + run_exports: {} + size: 3007892 + timestamp: 1778770568019 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fortran-compiler-1.11.0-h9bea470_0.conda + sha256: 53e5562ede83b478ebe9f4fc3d3b4eff5b627883f48aa0bf412e8fd90b5d6113 + md5: d5596f445a1273ddc5ea68864c01b69f + depends: + - binutils + - c-compiler 1.11.0 h4d9bdce_0 + - gfortran + - gfortran_linux-64 14.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6656 + timestamp: 1753098722318 +- conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda + sha256: c934c385889c7836f034039b43b05ccfa98f53c900db03d8411189892ced090b + md5: 8462b5322567212beeb025f3519fb3e2 + depends: + - libfreetype 2.14.3 ha770c72_0 + - libfreetype6 2.14.3 h73754d4_0 + license: GPL-2.0-only OR FTL + run_exports: + weak: + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + size: 173839 + timestamp: 1774298173462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda + sha256: 858283ff33d4c033f4971bf440cebff217d5552a5222ba994c49be990dacd40d + md5: f9f81ea472684d75b9dd8d0b328cf655 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-or-later + run_exports: + weak: + - fribidi >=1.0.16,<2.0a0 + size: 61244 + timestamp: 1757438574066 +- conda: https://conda.anaconda.org/conda-forge/linux-64/frozenlist-1.8.0-py312h447239a_0.conda + sha256: 7f36a4fc42f6d4cb9c5b210b6604b54eba2e5745c92d76241b6f8fce446818d1 + md5: 6a42923f35087cc88a9fac31ef096ce6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 55016 + timestamp: 1779999817627 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.3.0-h0dff253_19.conda + sha256: e3f541c4be7296b800a972482b7a5e399614d5e3310d3d083257fbdb4df81ea0 + md5: 2dd149aa693db92758af3e685ef30439 + depends: + - conda-gcc-specs + - gcc_impl_linux-64 14.3.0 h235f0fe_19 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 29459 + timestamp: 1778268802660 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.3.0-h235f0fe_19.conda + sha256: 1e2500ca976d4831c953d1c6db7b238d2e6806910b930e3eb631b79ba5c3ba41 + md5: 99936dc616b7ce97b0468759b8a7c64e + depends: + - binutils_impl_linux-64 >=2.45 + - libgcc >=14.3.0 + - libgcc-devel_linux-64 14.3.0 hf649bbc_119 + - libgomp >=14.3.0 + - libsanitizer 14.3.0 h8f1669f_19 + - libstdcxx >=14.3.0 + - libstdcxx-devel_linux-64 14.3.0 h9f08a49_119 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 77667192 + timestamp: 1778268558509 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.3.0-h50e9bb6_27.conda + sha256: d7f427d6db94a5eed2a43b186f8dc17cc1fbeb03517442390a36f1cde02548b0 + md5: 90369fa27fae2f7bf7eaaccc34c793e2 + depends: + - gcc_impl_linux-64 14.3.0.* + - binutils_linux-64 + - sysroot_linux-64 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - libgcc >=14 + size: 29324 + timestamp: 1781279939286 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + sha256: 1c22e37f9d7e06e9e0582ee5a55c2ddd19ea75f71f44eb13b56f504ef5c37aa5 + md5: 5d355db3e937086e22cf4cb5fe19787c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libglib >=2.88.2,<3.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + run_exports: + weak: + - gdk-pixbuf >=2.44.7,<3.0a0 + size: 581631 + timestamp: 1782591374199 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran-14.3.0-he448592_7.conda + sha256: 7ab008dd3dc5e6ca0de2614771019a1d35480d51df6216c96b1cf6a5e660ee40 + md5: 94394acdc56dcb4d55dddf0393134966 + depends: + - gcc 14.3.0.* + - gcc_impl_linux-64 14.3.0.* + - gfortran_impl_linux-64 14.3.0.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 30407 + timestamp: 1759966108779 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-14.3.0-h1a219da_19.conda + sha256: aee591aab674d70829ecc1abaa7f2ad5fcccbfad7bafa5ebca1225c86c60f18f + md5: d5f5c8cc2a64220838a096041b7a7fb4 + depends: + - gcc_impl_linux-64 >=14.3.0 + - libgcc >=14.3.0 + - libgfortran5 >=14.3.0 + - libstdcxx >=14.3.0 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 18551249 + timestamp: 1778268735401 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_linux-64-14.3.0-h5ce6d8a_27.conda + sha256: b8e6bd99f0b7a5e8757a1cf9bf64edc6217206d8872fb9af5d8204e5b581b3d1 + md5: cedd6fb9fad7611ee0bcb0fb531d77f1 + depends: + - gfortran_impl_linux-64 14.3.0.* + - gcc_linux-64 ==14.3.0 h50e9bb6_27 + - binutils_linux-64 + - sysroot_linux-64 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - libgfortran5 >=14.3.0 + - libgfortran + - libgcc >=14 + size: 27545 + timestamp: 1781279939286 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gl2ps-1.4.2-h36e74d4_2.conda + sha256: e28a214c71590a09f75f1aaccf5795bbcfb99b00c2d6ef55d34320b4f47485bd + md5: 787c780ff43f9f79d78d01e476b81a7c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libpng >=1.6.55,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + run_exports: + weak: + - gl2ps >=1.4.2,<1.4.3.0a0 + size: 75835 + timestamp: 1773985381918 +- conda: https://conda.anaconda.org/conda-forge/linux-64/glew-2.3.0-h71661d4_0.conda + sha256: 535d152ee06e3d3015a5ab410dfea9574e1678e226fa166f859a0b9e1153e597 + md5: 7eefecda1c71c380bfc406d16e78bbee + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglu >=9.0.3,<9.1.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - glew >=2.3.0,<2.4.0a0 + size: 492673 + timestamp: 1766373546677 +- conda: https://conda.anaconda.org/conda-forge/linux-64/glslang-16.3.0-h96af755_0.conda + sha256: 3c9b6a90937a96ad27d160304cdbe5e9961db613aba2b84ff673429f0c61d48e + md5: d175cb2c14104728ada04883786a309d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - spirv-tools >=2026,<2027.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - glslang >=16,<17.0a0 + size: 1366082 + timestamp: 1777747028121 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda + sha256: 309cf4f04fec0c31b6771a5809a1909b4b3154a2208f52351e1ada006f4c750c + md5: c94a5994ef49749880a8139cf9afcbe1 + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: GPL-2.0-or-later OR LGPL-3.0-or-later + run_exports: + weak: + - gmp >=6.3.0,<7.0a0 + size: 460055 + timestamp: 1718980856608 +- conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-hecca717_0.conda + sha256: 885fa7d1d7e2ad9ed0a700ee0d81ceb49de278253082d517959b22d6336eecce + md5: cf09e9fc938518e91d0706572cadf17a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: LGPL-2.0-or-later + license_family: LGPL + run_exports: + weak: + - graphite2 >=1.3.15,<2.0a0 + size: 100054 + timestamp: 1780454302233 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-14.3.0-he448592_7.conda + sha256: 7acf0ee3039453aa69f16da063136335a3511f9c157e222def8d03c8a56a1e03 + md5: 91dc0abe7274ac5019deaa6100643265 + depends: + - gcc 14.3.0.* + - gxx_impl_linux-64 14.3.0.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 30403 + timestamp: 1759966121169 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-h2185e75_19.conda + sha256: a31694c26d6a525d44f81130ebf7b9abe18771b7eaecb2cf93630c0b8b8fb936 + md5: 8b867d053ed89743eeac52c3a50f112d + depends: + - gcc_impl_linux-64 14.3.0 h235f0fe_19 + - libstdcxx-devel_linux-64 14.3.0 h9f08a49_119 + - sysroot_linux-64 + - tzdata + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 15235650 + timestamp: 1778268773535 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-hd240bd5_27.conda + sha256: cf5a44d14732b79e3c2bc6ebe1dba4f6b9077939f3093c75e36ad340737b5c15 + md5: 41fae38a424bbc78438cfec6a4fde187 + depends: + - gxx_impl_linux-64 14.3.0.* + - gcc_linux-64 ==14.3.0 h50e9bb6_27 + - binutils_linux-64 + - sysroot_linux-64 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - libstdcxx >=14 + - libgcc >=14 + size: 27854 + timestamp: 1781279939286 +- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.2.1-h6083320_0.conda + sha256: da9901aa1e20cbc2369fda212039b294dd02bce95f005539bab840b7310bf7d0 + md5: 21ee4640b7c2d94e584349fa12b29b9a + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - graphite2 >=1.3.14,<2.0a0 + - icu >=78.3,<79.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.1,<3.0a0 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - harfbuzz >=14.2.1 + size: 2362258 + timestamp: 1780450503234 +- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda + sha256: 0d09b6dc1ce5c4005ae1c6a19dc10767932ef9a5e9c755cfdbb5189ac8fb0684 + md5: bd77f8da987968ec3927990495dc22e4 + depends: + - libgcc-ng >=12 + - libjpeg-turbo >=3.0.0,<4.0a0 + - libstdcxx-ng >=12 + - libzlib >=1.2.13,<2.0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - hdf4 >=4.2.15,<4.2.16.0a0 + size: 756742 + timestamp: 1695661547874 +- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda + sha256: ac57ce3abbda2a82d6192bc36fbd7fe96e867d84c999ef81a3da034dfd01a995 + md5: 9c6e11a83468e1d5decae516077a73fb + depends: + - __glibc >=2.17,<3.0.a0 + - libaec >=1.1.5,<2.0a0 + - libcurl >=8.20.0,<9.0a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.6,<4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - hdf5 >=1.14.6,<1.14.7.0a0 + size: 3721555 + timestamp: 1780581675871 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a + md5: c80d8a3b84358cb967fa81e7075fbc8a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 12723451 + timestamp: 1773822285671 +- conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.2.2-hde8ca8f_0.conda + sha256: 43f30e6fd8cbe1fef59da760d1847c9ceff3fb69ceee7fd4a34538b0927959dd + md5: c427448c6f3972c76e8a4474e0fe367b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - imath >=3.2.2,<3.2.3.0a0 + size: 160289 + timestamp: 1759983212466 +- conda: https://conda.anaconda.org/conda-forge/linux-64/intel-gmmlib-22.10.0-hb700be7_0.conda + sha256: bc231d69eb6663db0e09738fb916c5e5507147cf1ac60f364f964004e0b29bab + md5: 10909406c1b0e4b57f9f4f0eb0999af8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - intel-gmmlib >=22.10.0,<23.0a0 + size: 1013714 + timestamp: 1774422680665 +- conda: https://conda.anaconda.org/conda-forge/linux-64/intel-media-driver-26.1.6-hecca717_0.conda + sha256: 7cbd7fda22db70c64af64c9173434a4ede58e4f220bda52a044e469aa94c65cb + md5: aaf7c3db8c7c4533deb5449d3ba1c51f + depends: + - __glibc >=2.17,<3.0.a0 + - intel-gmmlib >=22.10.0,<23.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libva >=2.23.0,<3.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - intel-media-driver >=26.1.6,<26.2.0a0 + size: 8782375 + timestamp: 1776080148587 +- conda: https://conda.anaconda.org/conda-forge/linux-64/jsoncpp-1.9.7-h171cf75_2.conda + sha256: a9f1dbf79d9fcae2c025753a75aaa8aa7b0495adb9712f3494584e7fd2d7c17b + md5: 5b91c0a8935594212d8a43ee3097736e + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: LicenseRef-Public-Domain OR MIT + run_exports: + weak: + - jsoncpp >=1.9.7,<1.9.8.0a0 + size: 187942 + timestamp: 1782498192876 +- conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + sha256: 0960d06048a7185d3542d850986d807c6e37ca2e644342dd0c72feefcf26c2a4 + md5: b38117a3c920364aff79f870c984b4a3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-or-later + run_exports: + weak: + - keyutils >=1.6.3,<2.0a0 + size: 134088 + timestamp: 1754905959823 +- conda: https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py312h0a2e395_0.conda + sha256: eec7654c2d68f06590862c6e845cc70987b6d6559222b6f0e619dea4268f5dd5 + md5: cd74a9525dc74bbbf93cf8aa2fa9eb5b + depends: + - python + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 77120 + timestamp: 1773067050308 +- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + sha256: 9b07046870772f28740e3f6149f09ff222843733087a33c5540b169c6289652d + md5: 54157a1c8c0bb70f62dd0b17fba7e7f2 + depends: + - __glibc >=2.17,<3.0.a0 + - keyutils >=1.6.3,<2.0a0 + - libedit >=3.1.20250104,<3.2.0a0 + - libedit >=3.1.20250104,<4.0a0 + - libgcc >=14 + - libstdcxx >=14 + - openssl >=3.5.7,<4.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - krb5 >=1.22.2,<1.23.0a0 + size: 1388990 + timestamp: 1781859420533 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 + sha256: aad2a703b9d7b038c0f745b853c6bb5f122988fe1a7a096e0e606d9cbec4eaab + md5: a8832b479f93521a9e7b5b743803be51 + depends: + - libgcc-ng >=12 + license: LGPL-2.0-only + license_family: LGPL + run_exports: + weak: + - lame >=3.100,<3.101.0a0 + size: 508258 + timestamp: 1664996250081 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.conda + sha256: 112b5b9462572d970f4abd2912f76a25ee7db158b1e7260163d91dd8a630db84 + md5: 8b3ce45e929cd8e8e5f4d18586b56d8b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - lcms2 >=2.19.1,<3.0a0 + size: 251971 + timestamp: 1780211695895 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c + md5: 18335a698559cdbcd86150a48bf54ba6 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 728002 + timestamp: 1774197446916 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda + sha256: f84cb54782f7e9cea95e810ea8fef186e0652d0fa73d3009914fa2c1262594e1 + md5: a752488c68f2e7c456bcbd8f16eec275 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - lerc >=4.1.0,<5.0a0 + size: 261513 + timestamp: 1773113328888 +- conda: https://conda.anaconda.org/conda-forge/linux-64/level-zero-1.29.0-hb700be7_0.conda + sha256: d87cfc5eaa08eefff97d891ecb49faa958fcfc32a425767796269c4100d4e516 + md5: f3c3bc77c96af553f761af0e78bc8d9d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: {} + size: 875773 + timestamp: 1780142086148 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda + sha256: 32933de2d4fa6e6ffd949052815b49cb65a0649ad70007155c533ab97ea8cefd + md5: c4393db381bffa0a83a8d9e47b238106 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - abseil-cpp =20260526.0 + - libabseil-static =20260526.0=cxx17* + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - libabseil >=20260526.0,<20260527.0a0 + - libabseil =*=cxx17* + size: 1437712 + timestamp: 1780524559298 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda + sha256: 822e4ae421a7e9c04e841323526321185f6659222325e1a9aedec811c686e688 + md5: 86f7414544ae606282352fa1e116b41f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libaec >=1.1.5,<2.0a0 + size: 36544 + timestamp: 1769221884824 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.4-h96ad9f0_0.conda + sha256: 035eb8b54e03e72e42ef707420f9979c7427776ea99e0f1e3c969f92eb573f19 + md5: d3be7b2870bf7aff45b12ea53165babd + depends: + - libgcc >=13 + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + - libfreetype >=2.13.3 + - libfreetype6 >=2.13.3 + - fribidi >=1.0.10,<2.0a0 + - libiconv >=1.18,<2.0a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - harfbuzz >=11.0.1 + license: ISC + run_exports: + weak: + - libass >=0.17.4,<0.17.5.0a0 + size: 152179 + timestamp: 1749328931930 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.4.2-hf998032_1.conda + sha256: b831161d7c86f7dfeb58d7189176c3314d7c2ccb38b4e1c8b376557ae4238f0b + md5: 57845550bac29aeb754615c41b518f74 + depends: + - __glibc >=2.17,<3.0.a0 + - aom >=3.14.1,<3.15.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - libgcc >=14 + - rav1e >=0.8.1,<0.9.0a0 + - svt-av1 >=4.0.1,<4.0.2.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libavif16 >=1.4.2,<2.0a0 + size: 149787 + timestamp: 1780665914755 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-8_h4a7cf45_openblas.conda + build_number: 8 + sha256: b2da6bfd72a1c9cb143ccf64bf5b28790cb4eb58bd1cb978f6537b2322f7d48b + md5: 00fc660ab1b2f5ca07e92b4900d10c79 + depends: + - libopenblas >=0.3.33,<0.3.34.0a0 + - libopenblas >=0.3.33,<1.0a0 + constrains: + - blas 2.308 openblas + - mkl <2027 + - libcblas 3.11.0 8*_openblas + - liblapack 3.11.0 8*_openblas + - liblapacke 3.11.0 8*_openblas + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libblas >=3.11.0,<4.0a0 + size: 18804 + timestamp: 1779859100675 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-1.90.0-hd24cca6_1.conda + sha256: fef9f2977ac341fd0fd7802bccffff0f220e4896f6fef29040428071d0aa863b + md5: 4dfa9b413062a24e09938fb6f91af821 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - icu >=78.1,<79.0a0 + - libgcc >=14 + - liblzma >=5.8.1,<6.0a0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - boost-cpp <0.0a0 + license: BSL-1.0 + run_exports: {} + size: 3229874 + timestamp: 1766347309472 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-devel-1.90.0-hfcd1e18_1.conda + sha256: a46970575b8ec39fe103833ed988bc9d56292146b417aeddb333a94e980053b0 + md5: 5e5227b43bdd65d0028a322b2636d02e + depends: + - libboost 1.90.0 hd24cca6_1 + - libboost-headers 1.90.0 ha770c72_1 + constrains: + - boost-cpp <0.0a0 + license: BSL-1.0 + run_exports: + weak: + - libboost >=1.90.0,<1.91.0a0 + size: 38562 + timestamp: 1766347475462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-headers-1.90.0-ha770c72_1.conda + sha256: be43ec008a4fd297b5cdccea6c8e05bedd1d40315bdaefe4cc2f75363dab3f73 + md5: a1da1e4c15eb57bb506b33e283107dc5 + constrains: + - boost-cpp <0.0a0 + license: BSL-1.0 + run_exports: {} + size: 14676487 + timestamp: 1766347330772 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + sha256: 318f36bd49ca8ad85e6478bd8506c88d82454cc008c1ac1c6bf00a3c42fa610e + md5: 72c8fd1af66bd67bf580645b426513ed + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlicommon >=1.2.0,<1.3.0a0 + size: 79965 + timestamp: 1764017188531 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + sha256: 12fff21d38f98bc446d82baa890e01fd82e3b750378fedc720ff93522ffb752b + md5: 366b40a69f0ad6072561c1d09301c886 + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlidec >=1.2.0,<1.3.0a0 + size: 34632 + timestamp: 1764017199083 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + sha256: a0c15c79997820bbd3fbc8ecf146f4fe0eca36cc60b62b63ac6cf78857f1dd0d + md5: 4ffbb341c8b616aa2494b6afb26a0c5f + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlienc >=1.2.0,<1.3.0a0 + size: 298378 + timestamp: 1764017210931 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.78-hd0affe5_0.conda + sha256: cc8c9fc6ddf0fbd3d1275b558ae9abad6cda23bced268732e2da21a87bb358cd + md5: f9f17eab7f3df1c6fd4b1a548a2f683a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libcap >=2.78,<2.79.0a0 + size: 124335 + timestamp: 1775488792584 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-8_h0358290_openblas.conda + build_number: 8 + sha256: 1a2bc77bb26520255904a3d9b1f40e6bf0bf9d8d3405c7709dd162282820915a + md5: 33a413f1095f8325e5c30fde3b0d2445 + depends: + - libblas 3.11.0 8_h4a7cf45_openblas + constrains: + - blas 2.308 openblas + - liblapacke 3.11.0 8*_openblas + - liblapack 3.11.0 8*_openblas + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libcblas >=3.11.0,<4.0a0 + size: 18778 + timestamp: 1779859107964 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda + sha256: 205c4f19550f3647832ec44e35e6d93c8c206782bdd620c1d7cf66237580ff9c + md5: 49c553b47ff679a6a1e9fc80b9c5a2d4 + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - libcups >=2.3.3,<2.4.0a0 + size: 4518030 + timestamp: 1770902209173 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.21.0-hcf29cc6_0.conda + sha256: 93ab25f02666eb8696fa70d9c920f2e3b370742ee17e2758705038a72e11147f + md5: dcd79cabd5d435f48d75db3d81cb5874 + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - libnghttp2 >=1.68.1,<2.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.7,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + run_exports: + weak: + - libcurl >=8.21.0,<9.0a0 + size: 479582 + timestamp: 1782296544301 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda + sha256: aa8e8c4be9a2e81610ddf574e05b64ee131fab5e0e3693210c9d6d2fba32c680 + md5: 6c77a605a7a689d17d4819c0f8ac9a00 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libdeflate >=1.25,<1.26.0a0 + size: 73490 + timestamp: 1761979956660 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdovi-3.3.2-ha23c83e_4.conda + sha256: d15432f07f654583978712e034d308b103a8b4650f0fdec172b5031a8af2b6c9 + md5: b26a64dfb24fef32d3330e37ce5e4f44 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + run_exports: + weak: + - libdovi >=3.3.2,<4.0a0 + size: 311420 + timestamp: 1777838991858 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.127-hb03c661_0.conda + sha256: 7d3187c11b7ae66c5595a8afd5a7ce352a490527fdf6614cab129bc7f2c16ba3 + md5: d8d16b9b32a3c5df7e5b3350e2cbe058 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpciaccess >=0.19,<0.20.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libdrm >=2.4.127,<2.5.0a0 + size: 311505 + timestamp: 1778975798004 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + sha256: d789471216e7aba3c184cd054ed61ce3f6dac6f87a50ec69291b9297f8c18724 + md5: c277e0a4d549b03ac1e9d6cbbe3d017b + depends: + - ncurses + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libedit >=3.1.20250104,<3.2.0a0 + size: 134676 + timestamp: 1738479519902 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + sha256: 9a25ea93e8272785405a21d30f84e620befb1d545f6dfaae18f06103b5df0443 + md5: 75e9f795be506c96dd43cb09c7c8d557 + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: {} + size: 46500 + timestamp: 1779728188901 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_3.conda + sha256: e4b46919c9bb65930bce238bd2736110ed7b8c30e5cd5394e4e1edb48de54843 + md5: 5bc6d55503483aabe8a90c5e7f49a2a4 + depends: + - __glibc >=2.17,<3.0.a0 + - libegl 1.7.0 ha4b6fd6_3 + - libgl-devel 1.7.0 ha4b6fd6_3 + - xorg-libx11 + license: LicenseRef-libglvnd + run_exports: + weak: + - libegl >=1.7.0,<2.0a0 + size: 31718 + timestamp: 1779728222280 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda + sha256: 1cd6048169fa0395af74ed5d8f1716e22c19a81a8a36f934c110ca3ad4dd27b4 + md5: 172bf1cd1ff8629f2b1179945ed45055 + depends: + - libgcc-ng >=12 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libev >=4.33,<4.34.0a0 + size: 112766 + timestamp: 1702146165126 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 77856 + timestamp: 1781203599810 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda + sha256: e755e234236bdda3d265ae82e5b0581d259a9279e3e5b31d745dc43251ad64fb + md5: 47595b9d53054907a00d95e4d47af1d6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libogg >=1.3.5,<1.4.0a0 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libflac >=1.5.0,<1.6.0a0 + size: 424563 + timestamp: 1764526740626 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda + sha256: 38f014a7129e644636e46064ecd6b1945e729c2140e21d75bb476af39e692db2 + md5: e289f3d17880e44b633ba911d57a321b + depends: + - libfreetype6 >=2.14.3 + license: GPL-2.0-only OR FTL + run_exports: {} + size: 8049 + timestamp: 1774298163029 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda + sha256: 16f020f96da79db1863fcdd8f2b8f4f7d52f177dd4c58601e38e9182e91adf1d + md5: fb16b4b69e3f1dcfe79d80db8fd0c55d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpng >=1.6.55,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - freetype >=2.14.3 + license: GPL-2.0-only OR FTL + run_exports: {} + size: 384575 + timestamp: 1774298162622 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + sha256: 8e0a3b5e41272e5678499b5dfc4cddb673f9e935de01eb0767ce857001229f46 + md5: 57736f29cc2b0ec0b6c2952d3f101b6a + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_19 + - libgomp 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 1041084 + timestamp: 1778269013026 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda + sha256: 9dcf54adfaa5e861123c2da4f2f0451a685464ea7e5a41ad91cf67b31d658d98 + md5: 331ee9b72b9dff570d56b1302c5ab37d + depends: + - libgcc 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - libgcc + size: 27694 + timestamp: 1778269016987 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda + sha256: 561a42758ef25b9ce308c4e2cf56daee4f06138385a17e29a492cd928e00be6f + md5: 42bf7eca1a951735fa06c0e3c0d5c8e6 + depends: + - libgfortran5 15.2.0 h68bc16d_19 + constrains: + - libgfortran-ng ==15.2.0=*_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 27655 + timestamp: 1778269042954 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda + sha256: 057978bb69fea29ed715a9b98adf71015c31baecc4aeb2bfc20d4fd5d83579d4 + md5: 85072b0ad177c966294f129b7c04a2d5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15.2.0 + constrains: + - libgfortran 15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 2483673 + timestamp: 1778269025089 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + sha256: ec353b3076ed8e357ed961d0e9ff6997491cade0e603de5bd18a2e301ac78ebd + md5: f25206d7322c0e9648e8b83694d143ab + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + - libglx 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: {} + size: 133469 + timestamp: 1779728207669 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_3.conda + sha256: 41d7d864ad1f199bdb06ff6cc3931455c8af62f1d2071a08c6fa08affbcb678f + md5: 63e43d278ee5084813fe3c2edf4834ce + depends: + - __glibc >=2.17,<3.0.a0 + - libgl 1.7.0 ha4b6fd6_3 + - libglx-devel 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: + weak: + - libgl >=1.7.0,<2.0a0 + size: 115664 + timestamp: 1779728218325 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.2-h0d30a3d_0.conda + sha256: 4bee10e62796f01e4fa2b5849135b1cc061337fe9cf5eb9bd79e9664922ae0e4 + md5: 889febc66cd9e4190f80ef9718fa239b + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libiconv >=1.18,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - libffi >=3.5.2,<3.6.0a0 + - pcre2 >=10.47,<10.48.0a0 + constrains: + - glib >2.66 + license: LGPL-2.1-or-later + run_exports: + weak: + - libglib >=2.88.2,<3.0a0 + size: 4754220 + timestamp: 1782463895250 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda + sha256: a0105eb88f76073bbb30169312e797ed5449ebb4e964a756104d6e54633d17ef + md5: 8422fcc9e5e172c91e99aef703b3ce65 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libopengl >=1.7.0,<2.0a0 + - libstdcxx >=13 + license: SGI-B-2.0 + run_exports: + weak: + - libglu >=9.0.3,<9.1.0a0 + size: 325262 + timestamp: 1748692137626 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + sha256: e019ebe4e3f5cdf23e2f5e58ddf7ade27988c53820115b17b98f218ebcc87748 + md5: eb83f3f8cecc3e9bff9e250817fc69b6 + depends: + - __glibc >=2.17,<3.0.a0 + license: LicenseRef-libglvnd + run_exports: {} + size: 133586 + timestamp: 1779728183422 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + sha256: 2f74713c9ca408ea84e88a30a9028153e7b553e8bb42e06139eac9a753c27da9 + md5: ec3c4350aa0261bf7f87b8ca15c8e80e + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + - xorg-libx11 >=1.8.13,<2.0a0 + license: LicenseRef-libglvnd + run_exports: {} + size: 76586 + timestamp: 1779728199059 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_3.conda + sha256: a17ae2d4cb2de04a20882ae14ec3cc1958e868a4dec81e3d7eca30115ee50e94 + md5: 16b6330783ce0d1ae8d22782173b32c9 + depends: + - __glibc >=2.17,<3.0.a0 + - libglx 1.7.0 ha4b6fd6_3 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-xorgproto + license: LicenseRef-libglvnd + run_exports: + weak: + - libglx >=1.7.0,<2.0a0 + size: 27363 + timestamp: 1779728211402 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + sha256: 5abe4ab9d93f6c9757d654f1969ae2267d4505315c1f2f8fe705fd60af084f1b + md5: faac990cb7aedc7f3a2224f2c9b0c26c + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 603817 + timestamp: 1778268942614 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.13.0-default_he001693_1000.conda + sha256: 5041d295813dfb84652557839825880aae296222ab725972285c5abe3b6e4288 + md5: c197985b58bc813d26b42881f0021c82 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libhwloc >=2.13.0,<2.13.1.0a0 + size: 2436378 + timestamp: 1770953868164 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda + sha256: 8b70955d5e9a49d08945d4f8e2eab855b2efa5fce9cb9bc5e75d86764e6f2f38 + md5: 3a9428b74c403c71048104d38437b48c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 OR BSD-3-Clause + run_exports: + weak: + - libhwy >=1.4.0,<1.5.0a0 + size: 1435782 + timestamp: 1776989559668 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f + md5: 915f5995e94f60e9a4826e0b0920ee88 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 790176 + timestamp: 1754908768807 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda + sha256: 10056646c28115b174de81a44e23e3a0a3b95b5347d2e6c45cc6d49d35294256 + md5: 6178c6f2fb254558238ef4e6c56fb782 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + run_exports: + weak: + - libjpeg-turbo >=3.1.4.1,<4.0a0 + size: 633831 + timestamp: 1775962768273 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.2-h174a0a3_1.conda + sha256: 0c8a78c6a42a6e4c6de3a5e82d692f60400d43f4cc80591745f28b37daad9c70 + md5: 850f48943d6b4589800a303f0de6a816 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libhwy >=1.4.0,<1.5.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libjxl >=0.11,<1.0a0 + size: 1846962 + timestamp: 1777065125966 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda + build_number: 8 + sha256: 168e327d737059553e15cc6ec36d76b9bbb3931c2a7721555fd68b4c9348b247 + md5: 809be8ba8712c77bc7d44c2d99390dc4 + depends: + - libblas 3.11.0 8_h4a7cf45_openblas + constrains: + - blas 2.308 openblas + - libcblas 3.11.0 8*_openblas + - liblapacke 3.11.0 8*_openblas + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - liblapack >=3.11.0,<3.12.0a0 + size: 18790 + timestamp: 1779859115086 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-8_h6ae95b6_openblas.conda + build_number: 8 + sha256: 0b982865888a73fe7c2e7d8e8ee3738ac378b83012a7da1a12264a473cb2382b + md5: da17457f689df5c0f246d2f0b3798fd2 + depends: + - libblas 3.11.0 8_h4a7cf45_openblas + - libcblas 3.11.0 8_h0358290_openblas + - liblapack 3.11.0 8_h47877c9_openblas + constrains: + - blas 2.308 openblas + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - liblapacke >=3.11.0,<3.12.0a0 + size: 18824 + timestamp: 1779859122364 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.8-hf7376ad_1.conda + sha256: e9b5f301d6b001a9b8ce782157f56b75c92c4fbc9eba95dc6345c1139251d13b + md5: 298bb2483fc7d15396147cf1c1465359 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm22 >=22.1.8,<22.2.0a0 + size: 44320272 + timestamp: 1781788728739 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d + md5: b88d90cad08e6bc8ad540cb310a761fb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 113478 + timestamp: 1775825492909 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-devel-5.8.3-hb03c661_0.conda + sha256: 7858f6a173206bc8a5bdc8e75690483bb66c0dcc3809ac1cb43c561a4723623a + md5: 55c20edec8e90c4703787acaade60808 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - liblzma 5.8.3 hb03c661_0 + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 491429 + timestamp: 1775825511214 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.10.0-nompi_hb6f1874_104.conda + sha256: 7deab19f51934a5bfdf8eec10721d91a02c6a27632471527b07ece8f0d2c7a8e + md5: e9e60f617e5545e1a7de60c2d01f8029 + depends: + - __glibc >=2.17,<3.0.a0 + - blosc >=1.21.6,<2.0a0 + - bzip2 >=1.0.8,<2.0a0 + - hdf4 >=4.2.15,<4.2.16.0a0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libaec >=1.1.5,<2.0a0 + - libcurl >=8.19.0,<9.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + - libzip >=1.11.2,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.6,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libnetcdf >=4.10.0,<4.10.1.0a0 + size: 862900 + timestamp: 1776686244733 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + sha256: 663444d77a42f2265f54fb8b48c5450bfff4388d9c0f8253dd7855f0d993153f + md5: 2a45e7f8af083626f009645a6481f12d + depends: + - __glibc >=2.17,<3.0.a0 + - c-ares >=1.34.6,<2.0a0 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.5,<4.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libnghttp2 >=1.68.1,<2.0a0 + size: 663344 + timestamp: 1773854035739 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + sha256: 927fe72b054277cde6cb82597d0fcf6baf127dcbce2e0a9d8925a68f1265eef5 + md5: d864d34357c3b65a4b731f78c0801dc4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-only + license_family: GPL + run_exports: + weak: + - libnsl >=2.0.1,<2.1.0a0 + size: 33731 + timestamp: 1750274110928 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda + sha256: 3b3f19ced060013c2dd99d9d46403be6d319d4601814c772a3472fe2955612b0 + md5: 7c7927b404672409d9917d49bff5f2d6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-or-later + run_exports: + weak: + - libntlm >=1.8,<2.0a0 + size: 33418 + timestamp: 1734670021371 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda + sha256: ffb066ddf2e76953f92e06677021c73c85536098f1c21fcd15360dbc859e22e4 + md5: 68e52064ed3897463c0e958ab5c8f91b + depends: + - libgcc >=13 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libogg >=1.3.5,<1.4.0a0 + size: 218500 + timestamp: 1745825989535 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda + sha256: 3d9aa85648e5e18a6d66db98b8c4317cc426721ad7a220aa86330d1ccedc8903 + md5: 2d3278b721e40468295ca755c3b84070 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + constrains: + - openblas >=0.3.33,<0.3.34.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libopenblas >=0.3.33,<1.0a0 + size: 5931919 + timestamp: 1776993658641 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopencv-5.0.0-headless_hf42b598_2.conda + sha256: 3375ee4bfe45b1098fbb4bea435785754f6ba23c1bf499702ae1cd3fbda11bd8 + md5: 56ba00768d54e6417b3520c1b9c22d37 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + - ffmpeg >=8.1.2,<9.0a0 + - harfbuzz >=14.2.1 + - hdf5 >=1.14.6,<1.14.7.0a0 + - imath >=3.2.2,<3.2.3.0a0 + - libavif16 >=1.4.2,<2.0a0 + - libcblas >=3.9.0,<4.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libjxl >=0.11,<1.0a0 + - liblapack >=3.9.0,<4.0a0 + - liblapacke >=3.9.0,<4.0a0 + - libopenvino >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-batch-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-hetero-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-cpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-gpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-npu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-ir-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-onnx-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-paddle-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-pytorch-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-lite-frontend >=2026.2.1,<2026.2.2.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + - libtiff >=4.7.1,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openexr >=3.4.13,<3.5.0a0 + - openjpeg >=2.5.4,<3.0a0 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - libopencv >=5.0.0,<5.0.1.0a0 + size: 34970016 + timestamp: 1782350338483 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_3.conda + sha256: 90777039b48529283df5f16383fc399866024257a8bd93de583f4730db1ab30a + md5: c2bd8055a2e2dce7a7f32cfd02101fb6 + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: {} + size: 51767 + timestamp: 1779728204026 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-devel-1.7.0-ha4b6fd6_3.conda + sha256: 6958088c10e21bae95a3db5ff170b3e32a8b439736c1add7c037c312d4bd0b87 + md5: 50c6d76c6c5ec179ad463837f0f12a17 + depends: + - __glibc >=2.17,<3.0.a0 + - libopengl 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: + weak: + - libopengl >=1.7.0,<2.0a0 + size: 16667 + timestamp: 1779728214747 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-2026.2.1-h1f0fae8_1.conda + sha256: 7941fb9ba8c3a5a0a2401dc4120e8fcb561b96d928c43374eb93f545019a2858 + md5: ea41753f926f73966629d81fdf20ec6f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino >=2026.2.1,<2026.2.2.0a0 + size: 6823841 + timestamp: 1782219077259 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2026.2.1-h7e124b3_1.conda + sha256: 3ac14d36fa890840ae8474b8a9f0a094b8542fd8fbc409faf3d465c68f20aff0 + md5: 5698a64698e14e8a2e9e16f8f0de0e2e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 114628 + timestamp: 1782219097820 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2026.2.1-h7e124b3_1.conda + sha256: 499a472fc7b598ad3753b8f2afe60eb5a277d48eca9362e8aca094b2862587a7 + md5: 2ce088ef09292930d4cb3262ce7e144d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 250912 + timestamp: 1782219111223 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2026.2.1-hd41364c_1.conda + sha256: bec24379598a4405de171ad151945e79743c6bd049aceabf190b753c3f7a11da + md5: 02e71250f7ca786c4b183d0a39ef63ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 215488 + timestamp: 1782219123433 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2026.2.1-h1f0fae8_1.conda + sha256: eecc040a7838752a2dff9b4435a4c59bbc67b83e0c880457935b968206cb20b5 + md5: 7288f979a74cfe3fd4b32d8a0dc7baa4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 13637410 + timestamp: 1782219135415 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2026.2.1-h1f0fae8_1.conda + sha256: a47442ce578b022e19a306f963536a108cc79385f4e09d57a14a849b6a864604 + md5: c0a258b12f0c18c476b8344dbd6db8d5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - ocl-icd >=2.3.4,<3.0a0 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 12367381 + timestamp: 1782219178219 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-npu-plugin-2026.2.1-h1f0fae8_1.conda + sha256: 45a91feb68ccce90ad0fa86520572233ca20be56deae0c920f86133d020ad1e8 + md5: c214b149e108e92672e0ee097ebe16f7 + depends: + - __glibc >=2.17,<3.0.a0 + - level-zero >=1.29.0,<2.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 2630818 + timestamp: 1782219217519 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-ir-frontend-2026.2.1-hd41364c_1.conda + sha256: ebeba9a3ac9505ee69b556865b7d1b9fbbad01ca1ebe6a4249ff62c3dc677b47 + md5: 2d946aebcf06e9ba438880987050e975 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-ir-frontend >=2026.2.1,<2026.2.2.0a0 + size: 201061 + timestamp: 1782219232657 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2026.2.1-h607c73d_1.conda + sha256: 7b105c0102356352d6d9518a112ff6343dab6b8f32c837809117cd26cbf006df + md5: 3bd3599825189418ea14b2c9da3a6d87 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-onnx-frontend >=2026.2.1,<2026.2.2.0a0 + size: 1944558 + timestamp: 1782219246849 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2026.2.1-h607c73d_1.conda + sha256: af45c03d41ebe0b48c28b68be31ee919cb801ac5077164808a66db515ad6a316 + md5: 91e198085bff9d8fa02d4d947f026ba8 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-paddle-frontend >=2026.2.1,<2026.2.2.0a0 + size: 690240 + timestamp: 1782219261154 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2026.2.1-hecca717_1.conda + sha256: e6353874a36143ffb7db7ec2c3767fd5e3434a8eeff41a569bc46e68259f668f + md5: 152d6694f1d05b53319b8376cdd811e4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-pytorch-frontend >=2026.2.1,<2026.2.2.0a0 + size: 1226625 + timestamp: 1782219274006 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2026.2.1-h21c0c73_1.conda + sha256: cffe112815b8eb57528fdfdf8b39f6a0915884291147dab5bc2066d2bf123031 + md5: 89d2455ec2f065786856b0cd2ac1c0c6 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + - snappy >=1.2.2,<1.3.0a0 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-tensorflow-frontend >=2026.2.1,<2026.2.2.0a0 + size: 1284650 + timestamp: 1782219287644 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2026.2.1-hecca717_1.conda + sha256: 142e7b24173ca8c32dbdb29c60f33a56ffb21a4ed733c9d6ab160c3a213ff52e + md5: c1a50f20847df0a8cb462138153ab46f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-tensorflow-lite-frontend >=2026.2.1,<2026.2.2.0a0 + size: 501906 + timestamp: 1782219300706 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda + sha256: f1061a26213b9653bbb8372bfa3f291787ca091a9a3060a10df4d5297aad74fd + md5: 2446ac1fe030c2aa6141386c1f5a6aed + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libopus >=1.6.1,<2.0a0 + size: 324993 + timestamp: 1768497114401 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_0.conda + sha256: f41721636a7c2e51bc2c642e1127955ab9c81145470714fdaac44d4d09e4af41 + md5: 33082e13b4769b48cfeb648e15bfe3fc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libpciaccess >=0.19,<0.20.0a0 + size: 29147 + timestamp: 1773533027610 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libplacebo-7.360.1-h9eeb4b2_0.conda + sha256: 26cbbd3d7b91801826c779c3f7e87d071856d5cbe3d55b22777ca0d984fb02ed + md5: e6324dfe6c02e0736bb9235f8ef3c8a6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libdovi >=3.3.2,<4.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - lcms2 >=2.19,<3.0a0 + - shaderc >=2026.2,<2026.3.0a0 + license: LGPL-2.1-or-later + run_exports: + weak: + - libplacebo >=7.360.1,<7.361.0a0 + size: 549348 + timestamp: 1777835950707 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda + sha256: 377cfe037f3eeb3b1bf3ad333f724a64d32f315ee1958581fc671891d63d3f89 + md5: eba48a68a1a2b9d3c0d9511548db85db + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: zlib-acknowledgement + run_exports: + weak: + - libpng >=1.6.58,<1.7.0a0 + size: 317729 + timestamp: 1776315175087 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpq-18.4-hd5a49e9_1.conda + sha256: f7232cb79a31f5a5bcd499aea930b469cde8b96d26db9541022493fd274d2a6e + md5: 6c9103e7ea739a3bb3505da49a4708c1 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - openldap >=2.6.13,<2.7.0a0 + - openssl >=3.5.7,<4.0a0 + license: PostgreSQL + run_exports: + weak: + - libpq >=18.4,<19.0a0 + size: 2709008 + timestamp: 1782580447454 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-7.35.1-h3a69515_1.conda + sha256: a14fc571ea573d733d2c18abb52123c09d56610dbf29d03cc85cf1470f5cc8ae + md5: c80393b49f041180405a587e5ac59b49 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libprotobuf >=7.35.1,<7.35.2.0a0 + size: 3942143 + timestamp: 1781325648920 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libraqm-0.10.5-h75b3fb1_0.conda + sha256: 36870c7e6362386c687f2f40d98de28f53ef84582ff65792f2f53981ede82681 + md5: 6855be9eb1d891cd5afb5eb90501c74c + depends: + - libgcc >=14 + - __glibc >=2.28,<3.0.a0 + - fribidi >=1.0.16,<2.0a0 + - harfbuzz >=14.2.1 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + license: MIT + license_family: MIT + run_exports: + weak: + - libraqm >=0.10.5,<0.11.0a0 + size: 29594 + timestamp: 1780835041392 +- conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.3-h4c96295_0.conda + sha256: 5571bd8239d71961d4e3ce972f865b3ea95a91ce0b53d5749fe2dd24254ddbda + md5: 492c8d9b1c564c2e948b6cb4ba0f8261 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.0,<3.0a0 + - fonts-conda-ecosystem + - gdk-pixbuf >=2.44.6,<3.0a0 + - harfbuzz >=14.2.0 + - libgcc >=14 + - libglib >=2.88.1,<3.0a0 + - libxml2-16 >=2.14.6 + - pango >=1.56.4,<2.0a0 + constrains: + - __glibc >=2.17 + license: LGPL-2.1-or-later + run_exports: + weak: + - librsvg >=2.62.3,<3.0a0 + size: 3476570 + timestamp: 1780450632624 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.3.0-h8f1669f_19.conda + sha256: 8766de5423b0a510e2b1bdd1963d0554bdad2119f3e31d8fbd4189af434235ca + md5: 007796e5a595bbc7df4a5e1580d72e1a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14.3.0 + - libstdcxx >=14.3.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + weak: + - libsanitizer 14.3.0 + size: 7947790 + timestamp: 1778268494844 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda + sha256: 57cb5f92110324c04498b96563211a1bca6a74b2918b1e8df578bfed03cc32e4 + md5: 067590f061c9f6ea7e61e3b2112ed6b3 + depends: + - __glibc >=2.17,<3.0.a0 + - lame >=3.100,<3.101.0a0 + - libflac >=1.5.0,<1.6.0a0 + - libgcc >=14 + - libogg >=1.3.5,<1.4.0a0 + - libopus >=1.5.2,<2.0a0 + - libstdcxx >=14 + - libvorbis >=1.3.7,<1.4.0a0 + - mpg123 >=1.32.9,<1.33.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + run_exports: + weak: + - libsndfile >=1.2.2,<1.3.0a0 + size: 355619 + timestamp: 1765181778282 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda + sha256: 365376f4815e5e80def2b3462a2419708b7c292da0da85278386c2618621fff4 + md5: 4aed8e657e9ff156bdbe849b4df44389 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.3,<4.0a0 + size: 962119 + timestamp: 1782519076616 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + sha256: fa39bfd69228a13e553bd24601332b7cfeb30ca11a3ca50bb028108fe90a7661 + md5: eecce068c7e4eddeb169591baac20ac4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libssh2 >=1.11.1,<2.0a0 + size: 304790 + timestamp: 1745608545575 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + sha256: dff1058c76ec6b8759e41cefa2508162d00e4a5e6721aa68ec3fd10094e702dc + md5: 5794b3bdc38177caf969dabd3af08549 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_19 + constrains: + - libstdcxx-ng ==15.2.0=*_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 5852044 + timestamp: 1778269036376 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda + sha256: 0672b6b6e1791c92e8eccad58081a99d614fcf82bca5841f9dfa3c3e658f83b9 + md5: e5ce228e579726c07255dbf90dc62101 + depends: + - libstdcxx 15.2.0 h934c35e_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - libstdcxx + size: 27776 + timestamp: 1778269074600 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-h084b8d7_1.conda + sha256: 2293884d59cf0436c37fc0a4bad71011a8de2a6913610d1c701a7703377c1f75 + md5: ea0da9c20bbb221b530810c3c68bbe62 + depends: + - __glibc >=2.17,<3.0.a0 + - libcap >=2.78,<2.79.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + run_exports: {} + size: 493022 + timestamp: 1780084748140 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libtheora-1.1.1-h4ab18f5_1006.conda + sha256: 50c8cd416ac8425e415264de167b41ae8442de22a91098dfdd993ddbf9f13067 + md5: 553281a034e9cf8693c9df49f6c78ea1 + depends: + - libgcc-ng >=12 + - libogg 1.3.* + - libogg >=1.3.5,<1.4.0a0 + - libvorbis 1.3.* + - libvorbis >=1.3.7,<1.4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libtheora >=1.1.1,<1.2.0a0 + size: 328924 + timestamp: 1719667859099 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda + sha256: e5f8c38625aa6d567809733ae04bb71c161a42e44a9fa8227abe61fa5c60ebe0 + md5: cd5a90476766d53e901500df9215e927 + depends: + - __glibc >=2.17,<3.0.a0 + - lerc >=4.0.0,<5.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libgcc >=14 + - libjpeg-turbo >=3.1.0,<4.0a0 + - liblzma >=5.8.1,<6.0a0 + - libstdcxx >=14 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + run_exports: + weak: + - libtiff >=4.7.1,<4.8.0a0 + size: 435273 + timestamp: 1762022005702 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-h084b8d7_1.conda + sha256: 287d05680e49eea51b8145fbf34bc213c0618b04f32e450e9da5d715e5134e38 + md5: 89e5671a076d99516a6acd72a35b1640 + depends: + - __glibc >=2.17,<3.0.a0 + - libcap >=2.78,<2.79.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + run_exports: {} + size: 145969 + timestamp: 1780084753104 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libunwind-1.8.3-h65a8314_0.conda + sha256: 71c8b9d5c72473752a0bb6e91b01dd209a03916cb71f36cc6a564e3a2a132d7a + md5: e179a69edd30d75c0144d7a380b88f28 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libunwind >=1.8.3,<1.9.0a0 + size: 75995 + timestamp: 1757032240102 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liburing-2.14-hb700be7_0.conda + sha256: 3d17b7aa90610afc65356e9e6149aeac0b2df19deda73a51f0a09cf04fd89286 + md5: 56f65185b520e016d29d01657ac02c0d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - liburing >=2.14,<2.15.0a0 + size: 154203 + timestamp: 1770566529700 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda + sha256: 89c84f5b26028a9d0f5c4014330703e7dff73ba0c98f90103e9cef6b43a5323c + md5: d17e3fb595a9f24fa9e149239a33475d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libudev1 >=257.4 + license: LGPL-2.1-or-later + run_exports: + weak: + - libusb >=1.0.29,<2.0a0 + size: 89551 + timestamp: 1748856210075 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f + md5: 01bb81d12c957de066ea7362007df642 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libuuid >=2.42.2,<3.0a0 + size: 40017 + timestamp: 1781625522462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda + sha256: e28e4519223f78b3163599ca89c3f2d80bfb53e907e7fc74e806e60d1efa578b + md5: 4e33d49bf4fc853855a3b00643aa5484 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libuv >=1.52.1,<2.0a0 + size: 419935 + timestamp: 1779396012261 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libva-2.23.0-he1eb515_0.conda + sha256: 255c7d00b54e26f19fad9340db080716bced1d8539606e2b8396c57abd40007c + md5: 25813fe38b3e541fc40007592f12bae5 + depends: + - __glibc >=2.17,<3.0.a0 + - libdrm >=2.4.125,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglx >=1.7.0,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - wayland >=1.24.0,<2.0a0 + - wayland-protocols + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libva >=2.23.0,<3.0a0 + size: 221308 + timestamp: 1765652453244 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda + sha256: ca494c99c7e5ecc1b4cd2f72b5584cef3d4ce631d23511184411abcbb90a21a5 + md5: b4ecbefe517ed0157c37f8182768271c + depends: + - libogg + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - libogg >=1.3.5,<1.4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libvorbis >=1.3.7,<1.4.0a0 + size: 285894 + timestamp: 1753879378005 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvpl-2.16.0-h54a6638_0.conda + sha256: 38850657dd6835613ef16b34895a54bea98bc7639db6a649c886b331635714fc + md5: 9f6b0090c3902b2c763a16f7dace7b6e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - intel-media-driver >=26.1.2,<26.2.0a0 + - libva >=2.23.0,<3.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libvpl >=2.16.0,<2.17.0a0 + size: 287992 + timestamp: 1772980546550 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvpx-1.15.2-hecca717_0.conda + sha256: 8e1119977f235b488ab32d540c018d3fd1eccefc3dd3859921a0ff555d8c10d2 + md5: 10f5008f1c89a40b09711b5a9cdbd229 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libvpx >=1.15.2,<1.16.0a0 + size: 1070048 + timestamp: 1762010217363 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.341.0-h5279c79_0.conda + sha256: a68280d57dfd29e3d53400409a39d67c4b9515097eba733aa6fe00c880620e2b + md5: 31ad065eda3c2d88f8215b1289df9c89 + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + constrains: + - libvulkan-headers 1.4.341.0.* + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libvulkan-loader >=1.4.341.0,<2.0a0 + size: 199795 + timestamp: 1770077125520 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda + sha256: 3aed21ab28eddffdaf7f804f49be7a7d701e8f0e46c856d801270b470820a37b + md5: aea31d2e5b1091feca96fcfe945c3cf9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libwebp-base >=1.6.0,<2.0a0 + size: 429011 + timestamp: 1752159441324 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda + sha256: 666c0c431b23c6cec6e492840b176dde533d48b7e6fb8883f5071223433776aa + md5: 92ed62436b625154323d40d5f2f11dd7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - pthread-stubs + - xorg-libxau >=1.0.11,<2.0a0 + - xorg-libxdmcp + license: MIT + license_family: MIT + run_exports: + weak: + - libxcb >=1.17.0,<2.0a0 + size: 395888 + timestamp: 1727278577118 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c + md5: 5aa797f8787fe7a17d1b0821485b5adc + depends: + - libgcc-ng >=12 + license: LGPL-2.1-or-later + run_exports: + weak: + - libxcrypt >=4.4.36 + size: 100393 + timestamp: 1702724383534 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + sha256: 046f2ff4acebd8729fac03e99c8c307dfb48b6a32894ba8c11576e78f6e76e43 + md5: dc8b067e22b414172bedd8e3f03f3c95 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - xkeyboard-config + - xorg-libxau >=1.0.12,<2.0a0 + license: MIT/X11 Derivative + license_family: MIT + run_exports: + weak: + - libxkbcommon >=1.13.2,<2.0a0 + size: 851166 + timestamp: 1780213397575 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbfile-1.2.0-hb03c661_0.conda + sha256: 49a0a85e595d1d6c7f355e13793fbcb650d42182f1051f81b15ae754f7c5692a + md5: f330a1b107cb5861f26dbb72a26f33e8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.13,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxkbfile >=1.2.0,<2.0a0 + size: 87066 + timestamp: 1778169480942 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + sha256: 3d44f737c5ae52d5af32682cc1530df433f401f8e58a7533926536244127572a + md5: e79d2c2f24b027aa8d5ab1b1ba3061e7 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.3 + license: MIT + license_family: MIT + run_exports: {} + size: 559775 + timestamp: 1776376739004 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda + sha256: 3bc5551720c58591f6ea1146f7d1539c734ed1c40e7b9f5cb8cb7e900c509aba + md5: 995d8c8bad2a3cc8db14675a153dec2b + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 hca6bf5a_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.3 + size: 46810 + timestamp: 1776376751152 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda + sha256: 991e7348b0f650d495fb6d8aa9f8c727bdf52dabf5853c0cc671439b160dce48 + md5: a7b27c075c9b7f459f1c022090697cba + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.3.2,<4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libzip >=1.11.2,<2.0a0 + size: 109043 + timestamp: 1730442108429 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 + md5: d87ff7921124eccd67248aa483c23fec + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 63629 + timestamp: 1774072609062 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + sha256: 47326f811392a5fd3055f0f773036c392d26fdb32e4d8e7a8197eed951489346 + md5: 9de5350a85c4a20c685259b889aa6393 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - lz4-c >=1.10.0,<1.11.0a0 + size: 167055 + timestamp: 1733741040117 +- conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.0-py312h1c5ec97_0.conda + sha256: 4d5db0491814ce2e70053ae5ac9ecd0a4f7103adb6df0e6eb0dcb7638145e65b + md5: 847125fead148cb26f52f8c3413cea12 + depends: + - __glibc >=2.17,<3.0.a0 + - contourpy >=1.0.1 + - cycler >=0.10 + - fonttools >=4.22.0 + - freetype + - kiwisolver >=1.3.1 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libraqm >=0.10.5,<0.11.0a0 + - libstdcxx >=14 + - numpy >=1.23 + - numpy >=1.23,<3 + - packaging >=20.0 + - pillow >=8 + - pyparsing >=2.3.1 + - python >=3.12,<3.13.0a0 + - python-dateutil >=2.7 + - python_abi 3.12.* *_cp312 + - qhull >=2020.2,<2020.3.0a0 + - tk >=8.6.13,<8.7.0a0 + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 9022139 + timestamp: 1781626880429 +- conda: https://conda.anaconda.org/conda-forge/linux-64/mesalib-26.0.3-h8cca3c9_0.conda + sha256: 3e6385e04e5a8f62599a5eb72b9a8c65ac143f0621d57f24688f103276d686eb + md5: 823e4ae1d60e97845773f7358585fc56 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - xorg-libxshmfence >=1.3.3,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + - libexpat >=2.7.4,<3.0a0 + - spirv-tools >=2026,<2027.0a0 + - libllvm22 >=22.1.1,<22.2.0a0 + - libxcb >=1.17.0,<2.0a0 + - libdrm >=2.4.125,<2.5.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - mesalib >=26.0.3,<26.1.0a0 + size: 2964576 + timestamp: 1773867966762 +- conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.32.9-hc50e24c_0.conda + sha256: 39c4700fb3fbe403a77d8cc27352fa72ba744db487559d5d44bf8411bb4ea200 + md5: c7f302fd11eeb0987a6a5e1f3aed6a21 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: LGPL-2.1-only + license_family: LGPL + run_exports: + weak: + - mpg123 >=1.32.9,<1.33.0a0 + size: 491140 + timestamp: 1730581373280 +- conda: https://conda.anaconda.org/conda-forge/linux-64/msgpack-python-1.2.1-py312h0a2e395_1.conda + sha256: c9764c77dd7f9c581c1d8a24c3024edf777cacfb5a4180de5badc6638dc8590f + md5: a142257c1b69e37cfefc66dc228a4d93 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 112800 + timestamp: 1782460774570 +- conda: https://conda.anaconda.org/conda-forge/linux-64/multidict-6.7.1-py312h8a5da7c_0.conda + sha256: 0da7e7f4e69bfd6c98eff92523e93a0eceeaec1c6d503d4a4cd0af816c3fe3dc + md5: 17c77acc59407701b54404cfd3639cac + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 100056 + timestamp: 1771611023053 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nanoflann-1.6.1-hff21bea_0.conda + sha256: 0141796f802039a40d3e2bce0d1183040f8cd2c53453455cb1401df1eb01d478 + md5: acccd21b34ac988d1b26d15c53b28f65 + license: BSD + run_exports: {} + size: 25915 + timestamp: 1728332440211 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 + md5: fc21868a1a5aacc937e7a18747acb8a5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 918956 + timestamp: 1777422145199 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda + sha256: fd2cbd8dfc006c72f45843672664a8e4b99b2f8137654eaae8c3d46dca776f63 + md5: 16c2a0e9c4a166e53632cfca4f68d020 + constrains: + - nlohmann_json-abi ==3.12.0 + license: MIT + license_family: MIT + run_exports: {} + size: 136216 + timestamp: 1758194284857 +- conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.0-py312h33ff503_0.conda + sha256: c8d5f70715fc6cd3dcd16fdd11b51879ed4484963f066b33fbaf20c4ffb153af + md5: 24f70d3db040fc69ee72cc38e55bc8e3 + depends: + - python + - libgcc >=14 + - libstdcxx >=14 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.12.* *_cp312 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - libblas >=3.9.0,<4.0a0 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - numpy >=1.25,<3 + size: 8911732 + timestamp: 1782112536981 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.4-hb03c661_1.conda + sha256: 75f3bf733523a338f73d6c276c4a26634877cd970edb558f2769d9fa52b100a9 + md5: c2871ba95727fd1382c05db66048b64c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - opencl-headers >=2025.6.13 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - ocl-icd >=2.3.4,<3.0a0 + size: 109598 + timestamp: 1780362789611 +- conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda + sha256: 8de2f0cd8a659b01abf86e7fbb8cea4f28ada62fd288429a2bbc040db1b98dd0 + md5: c930c8052d780caa41216af7de472226 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 55754 + timestamp: 1773844383536 +- conda: https://conda.anaconda.org/conda-forge/linux-64/opencv-5.0.0-headless_h47a1348_2.conda + noarch: python + sha256: bf57666d7efc3e967a3ec45af85fc72e2ffd829ac1724e7140a9f23806a55bc4 + md5: 6312b21a8d88670aad0ffb7240108ad2 + depends: + - libopencv 5.0.0 headless_hf42b598_2 + - py-opencv 5.0.0 headless_h75b7ce4_2 + license: Apache-2.0 + license_family: Apache + run_exports: {} + size: 48871 + timestamp: 1782350453544 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.4.13-hf734b31_1.conda + sha256: 17e3d2769dfc018748f0c09757b95744a5e942f74325b605d06303a69ee7955d + md5: 9db8aff65eaea058afc9fe93569b4b14 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - imath >=3.2.2,<3.2.3.0a0 + - openjph >=0.30.1,<0.31.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - openexr >=3.4.13,<3.5.0a0 + size: 1223929 + timestamp: 1782198396418 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-h65dd3cf_1.conda + sha256: 5317c5c23762f3fe1c8510565a2bb94c645e1470ff73b386315656404f7eb58a + md5: 69894a95220a17a66272daa701c387bc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - openh264 >=2.6.0,<2.6.1.0a0 + size: 726478 + timestamp: 1782685945856 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + sha256: 3900f9f2dbbf4129cf3ad6acf4e4b6f7101390b53843591c53b00f034343bc4d + md5: 11b3379b191f63139e29c0d19dee24cd + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpng >=1.6.50,<1.7.0a0 + - libstdcxx >=14 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - openjpeg >=2.5.4,<3.0a0 + size: 355400 + timestamp: 1758489294972 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.30.1-h8d634f6_0.conda + sha256: e405a62cc16604c4274d1d64387fa62ba5466cc65fa087ae45451d0150f4b698 + md5: 6143d4af035262f7e1b954e1cba9156d + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - libtiff >=4.7.1,<4.8.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - openjph >=0.30.1,<0.31.0a0 + size: 294315 + timestamp: 1782091151122 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda + sha256: 21c4f6c7f41dc9bec2ea2f9c80440d9a4d45a6f2ac13243e658f10dcf1044146 + md5: 680608784722880fbfe1745067570b00 + depends: + - __glibc >=2.17,<3.0.a0 + - cyrus-sasl >=2.1.28,<3.0a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - libstdcxx >=14 + - openssl >=3.5.6,<4.0a0 + license: OLDAP-2.8 + license_family: BSD + run_exports: + weak: + - openldap >=2.6.13,<2.7.0a0 + size: 786149 + timestamp: 1775741359582 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + sha256: d48f5c22b9897c01e4dff3680f1f57ceb02711ab9c62f74339b080419dfad34b + md5: 79dd2074b5cd5c5c6b2930514a11e22d + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3159683 + timestamp: 1781069855778 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hda50119_1.conda + sha256: 315b52bfa6d1a820f4806f6490d472581438a28e21df175290477caec18972b0 + md5: d53ffc0edc8eabf4253508008493c5bc + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.17.1,<3.0a0 + - fonts-conda-ecosystem + - fribidi >=1.0.16,<2.0a0 + - harfbuzz >=13.2.1 + - libexpat >=2.7.4,<3.0a0 + - libfreetype >=2.14.2 + - libfreetype6 >=2.14.2 + - libgcc >=14 + - libglib >=2.86.4,<3.0a0 + - libpng >=1.6.55,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + license: LGPL-2.1-or-later + run_exports: + weak: + - pango >=1.56.4,<2.0a0 + size: 458036 + timestamp: 1774281947855 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pcl-1.15.1-h1259f1f_14.conda + sha256: 9c890fbfb48000ec404540872a5e9c847104fd0f72cc8cd6efbc6aa1bfb94108 + md5: e147c64c0a4f74740eb23b5d9cb5731a + depends: + - __glibc >=2.17,<3.0.a0 + - eigen + - eigen-abi >=5.0.1.80,<5.0.1.81.0a0 + - flann >=1.9.2,<1.9.3.0a0 + - glew >=2.3.0,<2.4.0a0 + - libboost >=1.90.0,<1.91.0a0 + - libboost-devel + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libstdcxx >=14 + - nanoflann + - qhull >=2020.2,<2020.3.0a0 + - qt6-main >=6.11.1,<7.0a0 + - vtk + - vtk-base >=9.6.2,<9.6.3.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - pcl >=1.15.1,<1.15.2.0a0 + size: 18086219 + timestamp: 1781232903970 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + sha256: 5e6f7d161356fefd981948bea5139c5aa0436767751a6930cb1ca801ebb113ff + md5: 7a3bff861a6583f1889021facefc08b1 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - pcre2 >=10.47,<10.48.0a0 + size: 1222481 + timestamp: 1763655398280 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py312h50c33e8_0.conda + sha256: fa291f8915114733dc1df9f1627b8c63c517217c1eee1a6ede2ceb5e368cf27a + md5: 9e5609720e31213d4f39afe377f6217e + depends: + - python + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - lcms2 >=2.18,<3.0a0 + - libxcb >=1.17.0,<2.0a0 + - libjpeg-turbo >=3.1.2,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - openjpeg >=2.5.4,<3.0a0 + - python_abi 3.12.* *_cp312 + - tk >=8.6.13,<8.7.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - zlib-ng >=2.3.3,<2.4.0a0 + license: HPND + run_exports: {} + size: 1039561 + timestamp: 1775060059882 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda + sha256: 43d37bc9ca3b257c5dd7bf76a8426addbdec381f6786ff441dc90b1a49143b6a + md5: c01af13bdc553d1a8fbfff6e8db075f0 + depends: + - libgcc >=14 + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + run_exports: + weak: + - pixman >=0.46.4,<1.0a0 + size: 450960 + timestamp: 1754665235234 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda + sha256: c9601efb1af5391317e04eca77c6fe4d716bf1ca1ad8da2a05d15cb7c28d7d4e + md5: 1bee70681f504ea424fb07cdb090c001 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + license: GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 115175 + timestamp: 1720805894943 +- conda: https://conda.anaconda.org/conda-forge/linux-64/proj-9.8.1-he0df7b0_0.conda + sha256: dff6f355025b9a510d9093e29fd970fa1091e758b848c9dec814d96ae63a09ba + md5: b23619e5e9009eaa070ead0342034027 + depends: + - sqlite + - libtiff + - libcurl + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libtiff >=4.7.1,<4.8.0a0 + - libsqlite >=3.53.0,<4.0a0 + - libcurl >=8.19.0,<9.0a0 + constrains: + - proj4 ==999999999999 + license: MIT + license_family: MIT + run_exports: + weak: + - proj >=9.8.1,<9.9.0a0 + size: 3652144 + timestamp: 1775840249166 +- conda: https://conda.anaconda.org/conda-forge/linux-64/propcache-0.5.2-py312h8a5da7c_0.conda + sha256: c9138bbb53d4bac010526a8deace8cf764aac13fad5280d0a71556bad6c04d29 + md5: d681d6ad9fa2ca3c8cacb7f3b23d54f3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 51586 + timestamp: 1780037816755 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda + sha256: 9c88f8c64590e9567c6c80823f0328e58d3b1efb0e1c539c0315ceca764e0973 + md5: b3c17d95b5a10c6e64a21fa17573e70e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + run_exports: {} + size: 8252 + timestamp: 1726802366959 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda + sha256: 23c98a5000356e173568dc5c5770b53393879f946f3ace716bbdefac2a8b23d2 + md5: b11a4c6bf6f6f44e5e143f759ffa2087 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: MIT + license_family: MIT + run_exports: + weak: + - pugixml >=1.15,<1.16.0a0 + size: 118488 + timestamp: 1736601364156 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda + sha256: 0a0858c59805d627d02bdceee965dd84fde0aceab03a2f984325eec08d822096 + md5: b8ea447fdf62e3597cb8d2fae4eb1a90 + depends: + - __glibc >=2.17,<3.0.a0 + - dbus >=1.16.2,<2.0a0 + - libgcc >=14 + - libglib >=2.86.1,<3.0a0 + - libiconv >=1.18,<2.0a0 + - libsndfile >=1.2.2,<1.3.0a0 + - libsystemd0 >=257.10 + - libxcb >=1.17.0,<2.0a0 + constrains: + - pulseaudio 17.0 *_3 + license: LGPL-2.1-or-later + license_family: LGPL + run_exports: + weak: + - pulseaudio-client >=17.0,<17.1.0a0 + size: 750785 + timestamp: 1763148198088 +- conda: https://conda.anaconda.org/conda-forge/linux-64/py-opencv-5.0.0-headless_h75b7ce4_2.conda + noarch: python + sha256: b952865d82e01faa0671c10416c52edd82e1c44352674218c0e0798260eb22e1 + md5: 3022e53aa371af2fd15007ce329fc325 + depends: + - _python_abi3_support 1.* + - cpython >=3.10 + - libopencv 5.0.0 headless_hf42b598_2 + - numpy >=1.21,<3 + - python >=3.10 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - py-opencv >=5.0.0,<6.0a0 + size: 3837601 + timestamp: 1782350448196 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda + sha256: a44655c1c3e1d43ed8704890a91e12afd68130414ea2c0872e154e5633a13d7e + md5: 7eccb41177e15cc672e1babe9056018e + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.7.4,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libuuid >=2.41.3,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.5,<4.0a0 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + constrains: + - python_abi 3.12.* *_cp312 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.12.* *_cp312 + noarch: + - python + size: 31608571 + timestamp: 1772730708989 +- conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda + sha256: 776363493bad83308ba30bcb88c2552632581b143e8ee25b1982c8c743e73abc + md5: 353823361b1d27eb3960efb076dfcaf6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: LicenseRef-Qhull + run_exports: + weak: + - qhull >=2020.2,<2020.3.0a0 + size: 552937 + timestamp: 1720813982144 +- conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.11.1-pl5321h16c4a6b_1.conda + sha256: aefbc43bde188ff4027d480da99c7fa9e8e6341e9762e065190239cb9b99bb1c + md5: 331d660aef48fec733a878dd1f8f4206 + depends: + - libxcb + - xcb-util + - xcb-util-wm + - xcb-util-keysyms + - xcb-util-image + - xcb-util-renderutil + - xcb-util-cursor + - libgl-devel + - libegl-devel + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - xcb-util >=0.4.1,<0.5.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - pcre2 >=10.47,<10.48.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - xorg-libxxf86vm >=1.1.7,<2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libpq >=18.4,<19.0a0 + - xorg-libice >=1.1.2,<2.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - wayland >=1.25.0,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xcb-util-keysyms >=0.4.1,<0.5.0a0 + - libpng >=1.6.58,<1.7.0a0 + - harfbuzz >=14.2.1 + - xcb-util-cursor >=0.1.6,<0.2.0a0 + - xorg-libxcursor >=1.2.3,<2.0a0 + - libcups >=2.3.3,<2.4.0a0 + - libxcb >=1.17.0,<2.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libdrm >=2.4.127,<2.5.0a0 + - xorg-libxcomposite >=0.4.7,<1.0a0 + - xcb-util-image >=0.4.0,<0.5.0a0 + - xcb-util-wm >=0.4.2,<0.5.0a0 + - zstd >=1.5.7,<1.6.0a0 + - krb5 >=1.22.2,<1.23.0a0 + - xcb-util-renderutil >=0.3.10,<0.4.0a0 + - icu >=78.3,<79.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - xorg-libsm >=1.2.6,<2.0a0 + - alsa-lib >=1.2.16,<1.3.0a0 + - openssl >=3.5.6,<4.0a0 + - libglib >=2.88.1,<3.0a0 + - libgl >=1.7.0,<2.0a0 + - libxkbcommon >=1.13.2,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - double-conversion >=3.4.0,<3.5.0a0 + - dbus >=1.16.2,<2.0a0 + - xorg-libxtst >=1.2.5,<2.0a0 + - libegl >=1.7.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + constrains: + - qt ==6.11.1 + license: LGPL-3.0-only + license_family: LGPL + run_exports: + weak: + - qt6-main >=6.11.1,<7.0a0 + size: 60185421 + timestamp: 1780593127053 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda + sha256: cf550bbc8e5ebedb6dba9ccaead3e07bd1cb86b183644a4c853e06e4b3ad5ac7 + md5: d83958768626b3c8471ce032e28afcd3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - rav1e >=0.8.1,<0.9.0a0 + size: 5595970 + timestamp: 1772540833621 +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rhash-1.4.6-hb9d3cd8_1.conda + sha256: d5c73079c1dd2c2a313c3bfd81c73dbd066b7eb08d213778c8bff520091ae894 + md5: c1c9b02933fdb2cfb791d936c20e887e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + run_exports: + weak: + - rhash >=1.4.6,<2.0a0 + size: 193775 + timestamp: 1748644872902 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda + sha256: 987ad072939fdd51c92ea8d3544b286bb240aefda329f9b03a51d9b7e777f9de + md5: cdd138897d94dc07d99afe7113a07bec + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libgl >=1.7.0,<2.0a0 + - sdl3 >=3.2.22,<4.0a0 + - libegl >=1.7.0,<2.0a0 + license: Zlib + run_exports: + weak: + - sdl2 >=2.32.56,<3.0a0 + size: 589145 + timestamp: 1757842881000 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.10-hdeec2a5_0.conda + sha256: 04fa7dab2b8f688e3fc4b7ae4522fd3935fb0601e3329cda8b40d63c60d6cc05 + md5: 845c0b154836c034f361668bec2a4f20 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - xorg-libxcursor >=1.2.3,<2.0a0 + - libusb >=1.0.29,<2.0a0 + - libxkbcommon >=1.13.2,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxi >=1.8.3,<2.0a0 + - liburing >=2.14,<2.15.0a0 + - libunwind >=1.8.3,<1.9.0a0 + - xorg-libxtst >=1.2.5,<2.0a0 + - wayland >=1.25.0,<2.0a0 + - dbus >=1.16.2,<2.0a0 + - libgl >=1.7.0,<2.0a0 + - xorg-libxscrnsaver >=1.2.4,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - libdrm >=2.4.127,<2.5.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - libegl >=1.7.0,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - libudev1 >=257.13 + license: Zlib + run_exports: + weak: + - sdl3 >=3.4.10,<4.0a0 + size: 2148830 + timestamp: 1780262823658 +- conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2026.2-h718be3e_0.conda + sha256: c6e3280867e54c97996a4fedda0ab72c92d48d1d69258bddf910130df72c169d + md5: 6438976979721e2f60ec47327d8d38df + depends: + - __glibc >=2.17,<3.0.a0 + - glslang >=16,<17.0a0 + - libgcc >=14 + - libstdcxx >=14 + - spirv-tools >=2026,<2027.0a0 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - shaderc >=2026.2,<2026.3.0a0 + size: 113684 + timestamp: 1777360595361 +- conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + sha256: 48f3f6a76c34b2cfe80de9ce7f2283ecb55d5ed47367ba91e8bb8104e12b8f11 + md5: 98b6c9dc80eb87b2519b97bcf7e578dd + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - snappy >=1.2.2,<1.3.0a0 + size: 45829 + timestamp: 1762948049098 +- conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.2-hb700be7_0.conda + sha256: 309d1a3317e91a03611bc960fc807cf2c0c5baacbfddea0f5636438a76c52256 + md5: 0c2b1d811632f1f4aa923450a002ff4f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - spirv-headers >=1.4.350.0,<1.4.350.1.0a0 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - spirv-tools >=2026,<2027.0a0 + size: 2392190 + timestamp: 1780139567779 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.3-hbc0de68_0.conda + sha256: ef17725138fa72aa5a0f8ccace9727831c4b333e39575f78fb5ad1755826b687 + md5: b345ea7f13e4cae9809c69b1d1af2c99 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libsqlite 3.53.3 h0c1763c_0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - readline >=8.3,<9.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.3,<4.0a0 + size: 206481 + timestamp: 1782519082269 +- conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.0.1-hecca717_0.conda + sha256: 4a1d2005153b9454fc21c9bad1b539df189905be49e851ec62a6212c2e045381 + md5: 2a2170a3e5c9a354d09e4be718c43235 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - svt-av1 >=4.0.1,<4.0.2.0a0 + size: 2619743 + timestamp: 1769664536467 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-hab88423_2.conda + sha256: 30cb9355c2fefc20ff1a3d6566b9714d5614086a2524c07721fc344eb20515ae + md5: 7073b15f9364ebc118998601ac6ca6a6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libhwloc >=2.13.0,<2.13.1.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 182331 + timestamp: 1778673758649 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-devel-2023.0.0-hab88423_2.conda + sha256: 47c82725569413b079632f5ad161b3bf2fa877d65dee1ff5c2c70a308a6150b5 + md5: 64a1e69ab772dd965d78be2734212667 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - tbb 2023.0.0 hab88423_2 + run_exports: + weak: + - tbb >=2023.0.0 + size: 1139342 + timestamp: 1778673771784 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3301196 + timestamp: 1769460227866 +- conda: https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py312h4c3975b_0.conda + sha256: 895bbfe9ee25c98c922799de901387d842d7c01cae45c346879865c6a907f229 + md5: 0b6c506ec1f272b685240e70a29261b8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: Apache + run_exports: {} + size: 410641 + timestamp: 1770909099497 +- conda: https://conda.anaconda.org/conda-forge/linux-64/utfcpp-4.09-ha770c72_0.conda + sha256: 18f84366d84b83bb4b2a6e0ac4487a5b4ee33c531faa2d822027fddf8225eed5 + md5: 99884244028fe76046e3914f90d4ad05 + license: BSL-1.0 + run_exports: {} + size: 14226 + timestamp: 1767012219987 +- conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.11.25-h26efc2c_0.conda + sha256: 64d5c1592d62e77191e7f61aae811db68d040c5507c5b1cb33010530148177dc + md5: fed7cb43d942f65256b6187fe30484b1 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - __glibc >=2.17 + license: Apache-2.0 OR MIT + run_exports: {} + size: 20451374 + timestamp: 1782538835059 +- conda: https://conda.anaconda.org/conda-forge/linux-64/viskores-1.1.1-cpu_hc82bd48_0.conda + sha256: 2ebe8d6ae8c302ca5bf4aec532cd75ebfb00240db1c14dad9a91bace65aa083d + md5: 24676eb7fd23ea3d8d69417f5f1224e0 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + - hdf5 >=1.14.6,<1.14.7.0a0 + - mesalib >=26.0.3,<26.1.0a0 + - glew >=2.3.0,<2.4.0a0 + - zfp >=1.0.1,<2.0a0 + track_features: + - viskores-p-0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - viskores >=1.1.1,<1.2.0a0 + size: 25058279 + timestamp: 1777494673829 +- conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-9.6.2-py312h244374b_2.conda + sha256: 9391ede8dca52caf6627b7f18b6c25f03e7e27511bf0f7b7103108ba82a7c60d + md5: 41509ad9bbded845f8d99d6c0a8c683b + depends: + - vtk-base >=9.6.2,<9.6.3.0a0 + - vtk-io-ffmpeg >=9.6.2,<9.6.3.0a0 + - libgl-devel + - libopengl-devel + - libboost-devel + - liblzma-devel + - tbb-devel + - eigen + - expat + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - vtk-base >=9.6.2,<9.6.3.0a0 + size: 28281 + timestamp: 1780078474771 +- conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-base-9.6.2-py312h5e1aeb2_2.conda + sha256: f750026e1ef70f92b291ed90e542e13f646ff12b6c243967ae8bac36a17b35aa + md5: aed0067520ed430bbfcdd9638e336e33 + depends: + - python + - utfcpp + - nlohmann_json + - cli11 + - numpy + - wslink + - matplotlib-base >=2.0.0 + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.8.1,<3.0a0 + - libglvnd >=1.7.0,<2.0a0 + - libglu >=9.0.3,<9.1.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libsqlite >=3.53.1,<4.0a0 + - libnetcdf >=4.10.0,<4.10.1.0a0 + - jsoncpp >=1.9.7,<1.9.8.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - python_abi 3.12.* *_cp312 + - qt6-main >=6.11.1,<6.12.0a0 + - pugixml >=1.15,<1.16.0a0 + - fmt >=12.1.0,<12.2.0a0 + - gl2ps >=1.4.2,<1.4.3.0a0 + - libxkbfile >=1.2.0,<2.0a0 + - libogg >=1.3.5,<1.4.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libglx >=1.7.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - liblzma >=5.8.3,<6.0a0 + - xorg-libxcursor >=1.2.3,<2.0a0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - eigen-abi >=5.0.1.80,<5.0.1.81.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.2,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - tbb >=2023.0.0 + - double-conversion >=3.4.0,<3.5.0a0 + - viskores >=1.1.1,<1.2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - proj >=9.8.1,<9.9.0a0 + - libtheora >=1.1.1,<1.2.0a0 + - libopengl >=1.7.0,<2.0a0 + constrains: + - libboost-headers >=1.90.0,<1.91.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - vtk-base >=9.6.2,<9.6.3.0a0 + size: 85788735 + timestamp: 1780078474771 +- conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-io-ffmpeg-9.6.2-py312h14f7233_2.conda + sha256: 8f9c24ee7f6916e1d45ce369f01bec96d6c382f51751d025878abf2e3b8d7a0d + md5: 43ffec13c8ef20c1bdc9d06691fad2f9 + depends: + - vtk-base ==9.6.2 py312h5e1aeb2_2 + - ffmpeg + - python_abi 3.12.* *_cp312 + - ffmpeg >=8.1.1,<9.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - vtk-io-ffmpeg >=9.6.2,<9.6.3.0a0 + size: 112846 + timestamp: 1780078474771 +- conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda + sha256: ea374d57a8fcda281a0a89af0ee49a2c2e99cc4ac97cf2e2db7064e74e764bdb + md5: 996583ea9c796e5b915f7d7580b51ea6 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.7.4,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - wayland >=1.25.0,<2.0a0 + size: 334139 + timestamp: 1773959575393 +- conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 + sha256: 175315eb3d6ea1f64a6ce470be00fa2ee59980108f246d3072ab8b977cb048a5 + md5: 6c99772d483f566d59e25037fea2c4b1 + depends: + - libgcc-ng >=12 + license: GPL-2.0-or-later + license_family: GPL + run_exports: + weak: + - x264 >=1!164.3095,<1!165 + size: 897548 + timestamp: 1660323080555 +- conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 + sha256: 76c7405bcf2af639971150f342550484efac18219c0203c5ee2e38b8956fe2a0 + md5: e7f6ed84d4623d52ee581325c1587a6b + depends: + - libgcc-ng >=10.3.0 + - libstdcxx-ng >=10.3.0 + license: GPL-2.0-or-later + license_family: GPL + run_exports: + weak: + - x265 >=3.5,<3.6.0a0 + size: 3357188 + timestamp: 1646609687141 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda + sha256: ad8cab7e07e2af268449c2ce855cbb51f43f4664936eff679b1f3862e6e4b01d + md5: fdc27cb255a7a2cc73b7919a968b48f0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libxcb >=1.17.0,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util >=0.4.1,<0.5.0a0 + size: 20772 + timestamp: 1750436796633 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda + sha256: c2be9cae786fdb2df7c2387d2db31b285cf90ab3bfabda8fa75a596c3d20fc67 + md5: 4d1fc190b99912ed557a8236e958c559 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libxcb >=1.13 + - libxcb >=1.17.0,<2.0a0 + - xcb-util-image >=0.4.0,<0.5.0a0 + - xcb-util-renderutil >=0.3.10,<0.4.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-cursor >=0.1.6,<0.2.0a0 + size: 20829 + timestamp: 1763366954390 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda + sha256: 94b12ff8b30260d9de4fd7a28cca12e028e572cbc504fd42aa2646ec4a5bded7 + md5: a0901183f08b6c7107aab109733a3c91 + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + - xcb-util >=0.4.1,<0.5.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-image >=0.4.0,<0.5.0a0 + size: 24551 + timestamp: 1718880534789 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda + sha256: 546e3ee01e95a4c884b6401284bb22da449a2f4daf508d038fdfa0712fe4cc69 + md5: ad748ccca349aec3e91743e08b5e2b50 + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-keysyms >=0.4.1,<0.5.0a0 + size: 14314 + timestamp: 1718846569232 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda + sha256: 2d401dadc43855971ce008344a4b5bd804aca9487d8ebd83328592217daca3df + md5: 0e0cbe0564d03a99afd5fd7b362feecd + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-renderutil >=0.3.10,<0.4.0a0 + size: 16978 + timestamp: 1718848865819 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda + sha256: 31d44f297ad87a1e6510895740325a635dd204556aa7e079194a0034cdd7e66a + md5: 608e0ef8256b81d04456e8d211eee3e8 + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-wm >=0.4.2,<0.5.0a0 + size: 51689 + timestamp: 1718844051451 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + sha256: 3b04afd5d1a65d2d27ac2d49a63b01ab8bcd875776779ec63e337370ed38afdc + md5: b233b41be0bf210989d57160ed39b394 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - xorg-libx11 >=1.8.13,<2.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 441670 + timestamp: 1782027360439 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + sha256: c12396aabb21244c212e488bbdc4abcdef0b7404b15761d9329f5a4a39113c4b + md5: fb901ff28063514abb6046c9ec2c4a45 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libice >=1.1.2,<2.0a0 + size: 58628 + timestamp: 1734227592886 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + sha256: 277841c43a39f738927145930ff963c5ce4c4dacf66637a3d95d802a64173250 + md5: 1c74ff8c35dcadf952a16f752ca5aa49 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libuuid >=2.38.1,<3.0a0 + - xorg-libice >=1.1.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libsm >=1.2.6,<2.0a0 + size: 27590 + timestamp: 1741896361728 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + sha256: 516d4060139dbb4de49a4dcdc6317a9353fb39ebd47789c14e6fe52de0deee42 + md5: 861fb6ccbc677bb9a9fb2468430b9c6a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libxcb >=1.17.0,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libx11 >=1.8.13,<2.0a0 + size: 839652 + timestamp: 1770819209719 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + sha256: 6bc6ab7a90a5d8ac94c7e300cc10beb0500eeba4b99822768ca2f2ef356f731b + md5: b2895afaf55bf96a8c8282a2e47a5de0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxau >=1.0.12,<2.0a0 + size: 15321 + timestamp: 1762976464266 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda + sha256: 048c103000af9541c919deef03ae7c5e9c570ffb4024b42ecb58dbde402e373a + md5: f2ba4192d38b6cef2bb2c25029071d90 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxcomposite >=0.4.7,<1.0a0 + size: 14415 + timestamp: 1770044404696 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + sha256: 832f538ade441b1eee863c8c91af9e69b356cd3e9e1350fff4fe36cc573fc91a + md5: 2ccd714aa2242315acaf0a67faea780b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + - xorg-libxrender >=0.9.11,<0.10.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxcursor >=1.2.3,<2.0a0 + size: 32533 + timestamp: 1730908305254 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + sha256: 43b9772fd6582bf401846642c4635c47a9b0e36ca08116b3ec3df36ab96e0ec0 + md5: b5fcc7172d22516e1f965490e65e33a4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxdamage >=1.1.6,<2.0a0 + size: 13217 + timestamp: 1727891438799 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + sha256: 25d255fb2eef929d21ff660a0c687d38a6d2ccfbcbf0cc6aa738b12af6e9d142 + md5: 1dafce8548e38671bea82e3f5c6ce22f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxdmcp >=1.1.5,<2.0a0 + size: 20591 + timestamp: 1762976546182 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + sha256: 79c60fc6acfd3d713d6340d3b4e296836a0f8c51602327b32794625826bd052f + md5: 34e54f03dfea3e7a2dcf1453a85f1085 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxext >=1.3.7,<2.0a0 + size: 50326 + timestamp: 1769445253162 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + sha256: 83c4c99d60b8784a611351220452a0a85b080668188dce5dfa394b723d7b64f4 + md5: ba231da7fccf9ea1e768caf5c7099b84 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxfixes >=6.0.2,<7.0a0 + size: 20071 + timestamp: 1759282564045 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + sha256: 495f99c8eacfa4ae2d8fed2a7f2105777af89acdc204df145d2bbbc380ac631b + md5: adba2e334082bb218db806d4c12277c9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxi >=1.8.3,<2.0a0 + size: 47717 + timestamp: 1779111857071 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + sha256: 80ed047a5cb30632c3dc5804c7716131d767089f65877813d4ae855ee5c9d343 + md5: e192019153591938acf7322b6459d36e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxrandr >=1.5.5,<2.0a0 + size: 30456 + timestamp: 1769445263457 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + sha256: 044c7b3153c224c6cedd4484dd91b389d2d7fd9c776ad0f4a34f099b3389f4a1 + md5: 96d57aba173e878a2089d5638016dc5e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxrender >=0.9.12,<0.10.0a0 + size: 33005 + timestamp: 1734229037766 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda + sha256: 58e8fc1687534124832d22e102f098b5401173212ac69eb9fd96b16a3e2c8cb2 + md5: 303f7a0e9e0cd7d250bb6b952cecda90 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxscrnsaver >=1.2.4,<2.0a0 + size: 14412 + timestamp: 1727899730073 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxshmfence-1.3.3-hb9d3cd8_0.conda + sha256: c0830fe9fa78d609cd9021f797307e7e0715ef5122be3f784765dad1b4d8a193 + md5: 9a809ce9f65460195777f2f2116bae02 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxshmfence >=1.3.3,<2.0a0 + size: 12302 + timestamp: 1734168591429 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + sha256: 752fdaac5d58ed863bbf685bb6f98092fe1a488ea8ebb7ed7b606ccfce08637a + md5: 7bbe9a0cc0df0ac5f5a8ad6d6a11af2f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxi >=1.7.10,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxtst >=1.2.5,<2.0a0 + size: 32808 + timestamp: 1727964811275 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda + sha256: 64db17baaf36fa03ed8fae105e2e671a7383e22df4077486646f7dbf12842c9f + md5: 665d152b9c6e78da404086088077c844 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxxf86vm >=1.1.7,<2.0a0 + size: 18701 + timestamp: 1769434732453 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-hb03c661_0.conda + sha256: 7a8c64938428c2bfd016359f9cb3c44f94acc256c6167dbdade9f2a1f5ca7a36 + md5: aa8d21be4b461ce612d8f5fb791decae + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: {} + size: 570010 + timestamp: 1766154256151 +- conda: https://conda.anaconda.org/conda-forge/linux-64/yarl-1.24.2-py312h8a5da7c_0.conda + sha256: 9906e3e09ea7b734325cce2ebe7ac9a1d645d49e71823bffa54d9bf157c6b3ed + md5: 348307a7ed6137b1022f3809e2762f39 + depends: + - __glibc >=2.17,<3.0.a0 + - idna >=2.0 + - libgcc >=14 + - multidict >=4.0 + - propcache >=0.2.1 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: Apache + run_exports: {} + size: 155061 + timestamp: 1779246264888 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zfp-1.0.1-h909a3a2_5.conda + sha256: 5fabe6cccbafc1193038862b0b0d784df3dae84bc48f12cac268479935f9c8b7 + md5: 6a0eb48e58684cca4d7acc8b7a0fd3c7 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zfp >=1.0.1,<2.0a0 + size: 277694 + timestamp: 1766549572069 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda + sha256: ea4e50c465d70236408cb0bfe0115609fd14db1adcd8bd30d8918e0291f8a75f + md5: 2aadb0d17215603a82a2a6b0afd9a4cb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Zlib + license_family: Other + run_exports: + weak: + - zlib-ng >=2.3.3,<2.4.0a0 + size: 122618 + timestamp: 1770167931827 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + sha256: a3967b937b9abf0f2a99f3173fa4630293979bd1644709d89580e7c62a544661 + md5: aaa2a381ccc56eac91d63b6c1240312f + depends: + - cpython + - python-gil + license: MIT + license_family: MIT + run_exports: {} + size: 8191 + timestamp: 1744137672556 +- conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.2-pyhd8ed1ab_0.conda + sha256: 6c6ddfeefead96d44f09c955b04967a579583af2dc63518faf029e46825e41ab + md5: 8a9936643c4a9565459c4a8eb5d4e3ff + depends: + - python >=3.10 + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 20727 + timestamp: 1779297825279 +- conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + sha256: 8dc149a6828d19bf104ea96382a9d04dae185d4a03cc6beb1bc7b84c428e3ca2 + md5: 421a865222cd0c9d83ff08bc78bf3a61 + depends: + - frozenlist >=1.1.0 + - python >=3.9 + - typing_extensions >=4.2 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 13688 + timestamp: 1751626573984 +- conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + sha256: 1b6124230bb4e571b1b9401537ecff575b7b109cc3a21ee019f65e083b8399ab + md5: c6b0543676ecb1fb2d7643941fe375f2 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 64927 + timestamp: 1773935801332 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + sha256: f8e3c730fa14ee3f170493779f06522c4acf89169f43db4f039727709b6419cf + md5: a9965dd99f683c5f444428f896635716 + depends: + - __unix + license: ISC + run_exports: {} + size: 128866 + timestamp: 1781708962055 +- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda + noarch: generic + sha256: d3e9bbd7340199527f28bbacf947702368f31de60c433a16446767d3c6aaf6fe + md5: f54c1ffb8ecedb85a8b7fcde3a187212 + depends: + - python >=3.12,<3.13.0a0 + - python_abi * *_cp312 + license: Python-2.0 + run_exports: {} + size: 46463 + timestamp: 1772728929620 +- conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + sha256: bb47aec5338695ff8efbddbc669064a3b10fe34ad881fb8ad5d64fbfa6910ed1 + md5: 4c2a8fef270f6c69591889b93f9f55c1 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 14778 + timestamp: 1764466758386 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + sha256: 58d7f40d2940dd0a8aa28651239adbf5613254df0f75789919c4e6762054403b + md5: 0c96522c6bdaed4b1566d11387caaf45 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 397370 + timestamp: 1566932522327 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + sha256: c52a29fdac682c20d252facc50f01e7c2e7ceac52aa9817aaf0bb83f7559ec5c + md5: 34893075a5c9e55cdafac56607368fc6 + license: OFL-1.1 + license_family: Other + run_exports: {} + size: 96530 + timestamp: 1620479909603 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + sha256: 00925c8c055a2275614b4d983e1df637245e19058d79fc7dd1a93b8d9fb4b139 + md5: 4d59c254e01d9cde7957100457e2d5fb + license: OFL-1.1 + license_family: Other + run_exports: {} + size: 700814 + timestamp: 1620479612257 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + sha256: 2821ec1dc454bd8b9a31d0ed22a7ce22422c0aef163c59f49dfdf915d0f0ca14 + md5: 49023d73832ef61042f6a237cb2687e7 + license: LicenseRef-Ubuntu-Font-Licence-Version-1.0 + license_family: Other + run_exports: {} + size: 1620504 + timestamp: 1727511233259 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + sha256: a997f2f1921bb9c9d76e6fa2f6b408b7fa549edd349a77639c9fe7a23ea93e61 + md5: fee5683a3f04bd15cbd8318b096a27ab + depends: + - fonts-conda-forge + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 3667 + timestamp: 1566974674465 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + sha256: 54eea8469786bc2291cc40bca5f46438d3e062a399e8f53f013b6a9f50e98333 + md5: a7970cd949a077b7cb9696379d338681 + depends: + - font-ttf-ubuntu + - font-ttf-inconsolata + - font-ttf-dejavu-sans-mono + - font-ttf-source-code-pro + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 4059 + timestamp: 1762351264405 +- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda + sha256: c75632ea624aa450a394f570749420c5a2e0997d0216bc29d5d45b0f39df0426 + md5: 577b04680ae422adb86fc60d7b940659 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 163869 + timestamp: 1781620148226 +- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + sha256: 41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a + md5: 86d9cba083cd041bfbf242a01a7a1999 + constrains: + - sysroot_linux-64 ==2.28 + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 1278712 + timestamp: 1765578681495 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.3.0-hf649bbc_119.conda + sha256: e1815bb11d5abe886979e95889d84310d83d078d36a3567ca67cbf57a3876d88 + md5: 7d517e32d656a8880d98c0e4fc8ddc2c + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 3091520 + timestamp: 1778268364856 +- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.3.0-h9f08a49_119.conda + sha256: 1b4263aa5d8c8c659e8e38b66868f42867347e0c8941513ee77269afc00a5186 + md5: d1a866495b9654ccfef5392b8541dc58 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 20199810 + timestamp: 1778268389428 +- conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + sha256: d09c47c2cf456de5c09fa66d2c3c5035aa1fa228a1983a433c47b876aa16ce90 + md5: 37293a85a0f4f77bbd9cf7aaefc62609 + depends: + - python >=3.9 + license: Apache-2.0 + license_family: Apache + run_exports: {} + size: 15851 + timestamp: 1749895533014 +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + sha256: 3906abfb6511a3bb309e39b9b1b7bc38f50a723971de2395489fd1f379255890 + md5: 4c06a92e74452cfa53623a81592e8934 + depends: + - python >=3.8 + - python + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 91574 + timestamp: 1777103621679 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda + sha256: 417fba4783e528ee732afa82999300859b065dc59927344b4859c64aae7182de + md5: 3687cc0b82a8b4c17e1f0eb7e47163d5 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 110893 + timestamp: 1769003998136 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + sha256: d6a17ece93bbd5139e02d2bd7dbfa80bee1a4261dced63f65f679121686bf664 + md5: 5b8d21249ff20967101ffa321cab24e8 + depends: + - python >=3.9 + - six >=1.5 + - python + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 233310 + timestamp: 1751104122689 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda + sha256: 97327b9509ae3aae28d27217a5d7bd31aff0ab61a02041e9c6f98c11d8a53b29 + md5: 32780d6794b8056b78602103a04e90ef + depends: + - cpython 3.12.13.* + - python_abi * *_cp312 + license: Python-2.0 + run_exports: {} + size: 46449 + timestamp: 1772728979370 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + build_number: 8 + sha256: 80677180dd3c22deb7426ca89d6203f1c7f1f256f2d5a94dc210f6e758229809 + md5: c3efd25ac4d74b1584d2f7a57195ddf1 + constrains: + - python 3.12.* *_cpython + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6958 + timestamp: 1752805918820 +- conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d + md5: 3339e3b65d58accf4ca4fb8748ab16b3 + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 18455 + timestamp: 1753199211006 +- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + sha256: c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b851 + md5: 13dc3adbc692664cd3beabd216434749 + depends: + - __glibc >=2.28 + - kernel-headers_linux-64 4.18.0 he073ed8_9 + - tzdata + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: + strong: + - __glibc >=2.28,<3.0.a0 + size: 24008591 + timestamp: 1765578833462 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 + md5: 0caa1af407ecff61170c9437a808404d + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 51692 + timestamp: 1756220668932 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + run_exports: {} + size: 119135 + timestamp: 1767016325805 +- conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.49-hd8ed1ab_0.conda + sha256: 04ce686cd187d379344f9b2be7b4da5f431b265dc0944a6b764fab9da9171948 + md5: 0839a3421140d4a9ba93fb988698fc00 + license: MIT + license_family: MIT + run_exports: {} + size: 147954 + timestamp: 1780946721169 +- conda: https://conda.anaconda.org/conda-forge/noarch/wslink-2.5.7-pyhd8ed1ab_0.conda + sha256: 894762dc1a6520888de7cbe8d6f59999a94005aad11951fddcfdbef85d726a2d + md5: 410dee7cbee308f33b01645f16f11efc + depends: + - aiohttp <4 + - msgpack-python >=1,<2 + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 36803 + timestamp: 1778826669944 diff --git a/packages/dimos-gpd-worker-demo/pixi.toml b/packages/dimos-gpd-worker-demo/pixi.toml new file mode 100644 index 0000000000..0d2c2d213f --- /dev/null +++ b/packages/dimos-gpd-worker-demo/pixi.toml @@ -0,0 +1,14 @@ +[workspace] +name = "dimos-gpd-worker-demo" +channels = ["conda-forge"] +platforms = ["linux-64"] + +[dependencies] +cmake = ">=3.20" +compilers = "*" +eigen = "*" +opencv = "*" +pcl = "*" +pkg-config = "*" +python = ">=3.11,<3.13" +uv = "*" diff --git a/packages/dimos-gpd-worker-demo/pyproject.toml b/packages/dimos-gpd-worker-demo/pyproject.toml new file mode 100644 index 0000000000..855c4941f3 --- /dev/null +++ b/packages/dimos-gpd-worker-demo/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "dimos-gpd-worker-demo" +version = "0.1.0" +description = "DimOS project-runtime worker demo for importing GPD native bindings." +requires-python = ">=3.11" +dependencies = [ + "dimos", + "gpd @ git+https://github.com/TomCC7/gpd.git@c088d8ae2f7965b067e9a12b3c0dacdbe9da924a", +] + +[tool.uv.sources] +dimos = { path = "../..", editable = true } + +[tool.hatch.metadata] +allow-direct-references = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/packages/dimos-gpd-worker-demo/src/dimos_gpd_worker_demo/__init__.py b/packages/dimos-gpd-worker-demo/src/dimos_gpd_worker_demo/__init__.py new file mode 100644 index 0000000000..b64287574d --- /dev/null +++ b/packages/dimos-gpd-worker-demo/src/dimos_gpd_worker_demo/__init__.py @@ -0,0 +1,11 @@ +from dimos_gpd_worker_demo.blueprint import ( + GPD_DEMO_ENV_NAME, + GpdImportProbe, + gpd_worker_demo_blueprint, +) + +__all__ = [ + "GPD_DEMO_ENV_NAME", + "GpdImportProbe", + "gpd_worker_demo_blueprint", +] diff --git a/packages/dimos-gpd-worker-demo/src/dimos_gpd_worker_demo/blueprint.py b/packages/dimos-gpd-worker-demo/src/dimos_gpd_worker_demo/blueprint.py new file mode 100644 index 0000000000..cc51dd139a --- /dev/null +++ b/packages/dimos-gpd-worker-demo/src/dimos_gpd_worker_demo/blueprint.py @@ -0,0 +1,48 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from pathlib import Path + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.core import rpc +from dimos.core.module import Module +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment + +GPD_DEMO_ENV_NAME = "dimos-gpd-worker-demo" +GPD_DEMO_PROJECT = Path(__file__).resolve().parents[2] + + +class GpdImportProbe(Module): + @rpc + def import_gpd_core(self) -> str: + """Import gpd.core in the worker runtime and report success.""" + import gpd.core as gpd_core + + module_file = getattr(gpd_core, "__file__", "") + module_name = getattr(gpd_core, "__name__", "gpd.core") + return f"gpd import ok: {module_name} ({module_file})" + + +def gpd_worker_demo_blueprint( + runtime: PythonProjectRuntimeEnvironment | None = None, +): + environment = runtime or PythonProjectRuntimeEnvironment( + name=GPD_DEMO_ENV_NAME, + project=GPD_DEMO_PROJECT, + ) + return autoconnect(GpdImportProbe.blueprint()).runtime_environments( + environment + ).runtime_placements({GpdImportProbe: environment.name}) diff --git a/packages/dimos-gpd-worker-demo/uv.lock b/packages/dimos-gpd-worker-demo/uv.lock new file mode 100644 index 0000000000..ed226c1564 --- /dev/null +++ b/packages/dimos-gpd-worker-demo/uv.lock @@ -0,0 +1,4221 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "addict" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ef/fd7649da8af11d93979831e8f1f8097e85e82d5bfeabc8c68b39175d8e75/addict-2.4.0.tar.gz", hash = "sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494", size = 9186, upload-time = "2020-11-21T16:21:31.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiohttp-jinja2" +version = "1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/39/da5a94dd89b1af7241fb7fc99ae4e73505b5f898b540b6aba6dc7afe600e/aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2", size = 53057, upload-time = "2023-11-18T15:30:52.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/90/65238d4246307195411b87a07d03539049819b022c01bcc773826f600138/aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7", size = 11736, upload-time = "2023-11-18T15:30:50.743Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "annotation-protocol" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/fd/612c96531b1c1d1c06e5d79547faea3f805785d67481b350f3f6a9cf6dc5/annotation_protocol-1.4.0.tar.gz", hash = "sha256:15d846a4984339bab6cbf80a44623219b8cb06b4f4fee0f22c31a255d16900f8", size = 8470, upload-time = "2026-01-19T08:48:27.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8b/71a5e1392dd3aca7ffeef0c3b10ea9b0e62959b5f39889702a06e11eda96/annotation_protocol-1.4.0-py3-none-any.whl", hash = "sha256:6fc66f1506f015db16fdd50fad18520cbb126a7902b27257c9fa521eb5efec60", size = 7834, upload-time = "2026-01-19T08:48:25.848Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "bleak" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-radios", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/df/05a3f80ca8e3f7f5b0dba68a9e618147c909ccdba1468f07487dc8d72a9d/bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f", size = 125293, upload-time = "2026-05-02T23:01:04.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "cmeel" +version = "0.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/46/ddc7df697e49cae32b1a97b3e1a3b47b815238f9059312f987bc62a2e756/cmeel-0.60.1.tar.gz", hash = "sha256:3e0b92eb933a3693ad3f1da8aae31defcbee5f25969daaf20e59c57d6a9474cf", size = 14972, upload-time = "2026-05-11T17:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/39/f2db2ff475d42222c70fe25c737028aeaafdd9c0aeba04e6b2dc66e7f93f/cmeel-0.60.1-py3-none-any.whl", hash = "sha256:3f92b68353a58b4d6b5a664ea96bf58a3fef0891a8ed570d3c153361bbbb94b7", size = 20612, upload-time = "2026-05-11T17:13:55.812Z" }, +] + +[[package]] +name = "cmeel-assimp" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-zlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/57/444bedd35567a8d6b61850b03aadf6b0d2d20737ee79c620c0e40f9a56dc/cmeel_assimp-6.0.5-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:840ab4bd398abbec5a612a731bb6f7fdc4e21b2708ae0dc0f99d7ff6d9992624", size = 10057855, upload-time = "2026-05-20T16:36:19.775Z" }, + { url = "https://files.pythonhosted.org/packages/6f/96/d4d128e074f59b79e90c78b8379127bd8953c72d1ad8a65229c23a09814f/cmeel_assimp-6.0.5-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d81f913e9efc8526a335900325906b85dc57891eeb7ece463714a107f14f63f", size = 9216472, upload-time = "2026-05-20T16:36:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/77/37/11de8f263a5cde3cde4796e344ff034674bdbb7a2381f6b5303d49b24def/cmeel_assimp-6.0.5-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:db6fe58f8bd633cb05ab9f390c576a4e774927ed8c343083fad4f939e834bf6e", size = 13709230, upload-time = "2026-05-20T16:36:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/62/66/7f12b2f003671828a46ab35964aed0a34632fcba5638b4049bdd918cf342/cmeel_assimp-6.0.5-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7b5ee36f3027de9b1bd73bd4294b60fed1bbc6c1524f94f64f69f38c5a49bf03", size = 14840275, upload-time = "2026-05-20T16:36:28.311Z" }, +] + +[[package]] +name = "cmeel-boost" +version = "1.90.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/01/ab2ea5b1ceae6283eb68be8beac529f294c70ac49d40b7873b0b639a7784/cmeel_boost-1.90.0.tar.gz", hash = "sha256:747d6d932838df26f50cdcb5983fa7532cad1d9ccce46c9f9d8b27b20a115981", size = 4085, upload-time = "2026-05-20T15:19:13.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/0c/3e8c134393001b5f7dcf8c8aa1673b2126b271e1d475541a1fe0c1ca8bac/cmeel_boost-1.90.0-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0881a638b53d19340bab8bcc21b108999b20a8ed3a4db0a761b53b8116ad551", size = 30799100, upload-time = "2026-05-20T15:17:52.887Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bf/3383e9bdf24176f9815c98f39a34901a9019a05b587d32d7e901e4fd5a91/cmeel_boost-1.90.0-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:853b2cfb491733d782ae163adc6378baaccd8dc9752870677498ccb0eaea125a", size = 30693292, upload-time = "2026-05-20T15:17:57.243Z" }, + { url = "https://files.pythonhosted.org/packages/d7/30/c19fdd54edbb1bef7d8c403752d7055ddbffcd757a947074517f3d18e414/cmeel_boost-1.90.0-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:653285a31001ad30a9b6fc95c0f86ee9f65b4ab5d868f649a2df3472db53d368", size = 35828987, upload-time = "2026-05-20T15:18:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/07/52/ff72d2e3950a09e9fe9714d5952597f695efd5efcdc388d3781cec1910f0/cmeel_boost-1.90.0-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14efe34db660c9aacb61247a7f9ae0ee6d7626ec9a3d89b8b27e62dba6fcaa4d", size = 36162775, upload-time = "2026-05-20T15:18:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/5c/a01c51d296f59bf36851e8d23387d7d09952c99ed9dbc26ed0c502ed05c3/cmeel_boost-1.90.0-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:506c315ff4b48c2e7d146787541319f22d9b63a96defba7f963e3402c5e5eca1", size = 30801862, upload-time = "2026-05-20T15:18:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3e/c6d597586bbef678f19c19f10b5b1f75203861d3acba41b2ec0cd3fca6d5/cmeel_boost-1.90.0-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34657195b40faf0d090a55ee736db4b69938241730b8786e4b3430e4487519de", size = 30695049, upload-time = "2026-05-20T15:18:15.845Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/3f590f6446afa13af6232c18020ed61c72418f94f060ae6cc30af28b6b7d/cmeel_boost-1.90.0-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0b9546ad7cdffd1d633bd878e8334adedb2efe099e72f965157e49c474cb463f", size = 35830172, upload-time = "2026-05-20T15:18:21.109Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/c4dd452193fc017e80a8b0abd03c4bcbf45cebb21b87006268062cf2df8b/cmeel_boost-1.90.0-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c306b9af69d13502a6598672a341deb4f549fa3eb8cfb363de190e7409919a4a", size = 36167681, upload-time = "2026-05-20T15:18:26.397Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a2/1298a943326a2676fb01315580097afdbb4ed7713eec8a43501ea734afad/cmeel_boost-1.90.0-0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:32eb2faf91bf7fcd1858a7a1adddb3b92d7c65d305b4800fc41e0a8a53d29534", size = 30801906, upload-time = "2026-05-20T15:18:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/be/54/d6eb64585a9ad92699ad43a035f0eccff3d6ab0df9938232329bc710c645/cmeel_boost-1.90.0-0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c4fb3a1e0b04a2c4c871f0e72b55f6f381b14b110002505276a7831be24d348", size = 30695070, upload-time = "2026-05-20T15:18:36.371Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a6/948afc3ad0eed1b1f0f482191293b4ea57e6da853842121cb6bdb36538bc/cmeel_boost-1.90.0-0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:83efcb881da48f3645d4eae4ddb2bde6e2ae241b3b0a9c9abae6bdfcc0ca4ae6", size = 35830386, upload-time = "2026-05-20T15:18:42.294Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/a153892d8d8f5624eed29a933779d6ff00c144dd9dc6f70b41f98377f39a/cmeel_boost-1.90.0-0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:deeefeae790e4a9ac200ece07c935faf415e48933ccffd7a692682f7f4c3f524", size = 36167733, upload-time = "2026-05-20T15:18:47.775Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e9/533f21bacafc50cb13ad9e305992910b3719d105e1a303508eb09338f4ef/cmeel_boost-1.90.0-0-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:022a3e3b059921e5147fd6f2b47f4ee787a83210157c6d70443b65190dcdfd5f", size = 30804533, upload-time = "2026-05-20T15:18:53.572Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/fcfbec49b9cbbb3814d1df8638aa4af75a2134578417f11e9dfaedb00a20/cmeel_boost-1.90.0-0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec217d419adbdc486af7deab664bccd6aa5d4d69878ce7df374c29482b10d731", size = 30695643, upload-time = "2026-05-20T15:18:58.999Z" }, + { url = "https://files.pythonhosted.org/packages/33/98/98c56099e55ad4532d539613bb8f4acbf61b3ece2d8964738ee721d6a73c/cmeel_boost-1.90.0-0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7527de3b45851da92fca44c61fb5f6562fd61e15d7f1dc071c63f55d672becc4", size = 35836813, upload-time = "2026-05-20T15:19:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/cdc8fe9402b6ef844e87c7aaedd411a63a827e1f332cdf590c8d8d3c929d/cmeel_boost-1.90.0-0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5fda3fb51276d3ec95d9e035af69b3c2c4032b681ff10d67918109043296b8e9", size = 36174146, upload-time = "2026-05-20T15:19:09.696Z" }, +] + +[[package]] +name = "cmeel-console-bridge" +version = "1.0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/13/2e9e9d23db8548aef975564055bdb4fb6da8a397a1e7df8cb61f5afebefb/cmeel_console_bridge-1.0.2.3.tar.gz", hash = "sha256:3b2837da7ab408e9d1a775c83c0a7772356062b3a3672e4ce247f2da71a8ecd9", size = 262061, upload-time = "2025-03-19T18:22:06.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/a7/527fa060e5881acb3b0a07bf1d803ccb831cb87739abb62b6bcd14f5aed3/cmeel_console_bridge-1.0.2.3-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:7aa19b2d006073a1fad55d32968c7d0c7136749e06f98405f4f73a71038a5c41", size = 21341, upload-time = "2025-03-19T18:21:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/bb/db/f8643a8766e8909e0dbfcda6191ca92454cf9a3fadd89be417db261601a1/cmeel_console_bridge-1.0.2.3-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c47d8c97cb120feed1c01f30845d16c67e4e8205941e3977951018972b9b8721", size = 21286, upload-time = "2025-03-19T18:21:57.984Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/9c79177152a220ab2e4ffa0140722165035f6a5c2abbed2912352bd7e7b9/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_17_i686.whl", hash = "sha256:cad9723ac44ab563cd23bf361b604733623d11847c4edf2a2b4ebd1d984ade09", size = 23740, upload-time = "2025-03-19T18:21:59.683Z" }, + { url = "https://files.pythonhosted.org/packages/92/65/5741de6f550fe701d0780546d97b283306676315a3e1f379a6038e8c0ab0/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:372942e9c44f681bfff377fba25b348801283aa6f3826a00e4195089bda9737a", size = 25762, upload-time = "2025-03-19T18:22:01.055Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/70e23c5570506bb39b56aa4d0f3a4a414e38082ddb33e86a48b546620121/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5bb1115ed38441b2396e732e10ec63d1e68445674f9f5d321f7985eb10e9aeef", size = 24477, upload-time = "2025-03-19T18:22:02.091Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/bfd5a255348902e39243ccc6eba693bce714b891cd3be5603a9bd50c6de5/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b8d084b797f592942208c2040b08e06b82f8832aa6c5e582ba6f1a4a653505b", size = 24970, upload-time = "2025-03-19T18:22:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/3ae074e9acb9e150a4d5d97f341c2064573cd5fe9e5af20ab58bf8c0020a/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:fb6753a9864217d969c4965389d66a476ac978136c03eadf1063b1619c359220", size = 24689, upload-time = "2025-03-19T18:22:04.084Z" }, + { url = "https://files.pythonhosted.org/packages/69/d0/321f74b7d4167a6c59bb7714a6899ba402d9fad611f62573b9d646107320/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9d446c0fc541413d8d2ceea3c1cfb9cbfd57938d6659c113121eca6c245caafe", size = 24404, upload-time = "2025-03-19T18:22:05.232Z" }, +] + +[[package]] +name = "cmeel-octomap" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ab/2fed2dbee13e4b39949591685419f1dbb691295e32a6bbbaf87edc005922/cmeel_octomap-1.10.0.tar.gz", hash = "sha256:bd79d1d17adede534de242e42e13ef0d9f04bdd27daf7d56c57f7c43670c9b05", size = 1694189, upload-time = "2025-01-06T17:57:05.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/22/ea67d35df31ec4bb2ed6e594b173c572c72dbd2a87e96906eac67b4af930/cmeel_octomap-1.10.0-4-py3-none-macosx_14_0_arm64.whl", hash = "sha256:c116eb151920d26ee2b2c1f656cd7526862006739817205f11f9366ab0ef6cb4", size = 639956, upload-time = "2025-01-06T17:56:56.826Z" }, + { url = "https://files.pythonhosted.org/packages/b2/da/07725a8c11224881f536ad252e97a3d9801b48e5e776017d5f00fb39b17f/cmeel_octomap-1.10.0-4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:76cc42553f54bae97584aaf0c7bc33753ff287e2738aa2ecac4820121101dd46", size = 1044402, upload-time = "2025-01-06T17:56:58.553Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/9617b7039afd6d17d3148f6f970d953f5e265d7736f8fdbca09c86e976a0/cmeel_octomap-1.10.0-4-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5fdb04546fff3accac5f8626c3fc15c3b99e94ab887793565e0b92cedaf96468", size = 1105037, upload-time = "2025-01-06T17:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/88c1d1eca1abf2387ee8263ac7e12708c8b1b5b70b46a0bd9f43b485165b/cmeel_octomap-1.10.0-4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:84a7376cfced954bb7e3e347afbd02bdc1c83066b995afbdd0fb1e2d9f57ebec", size = 1108359, upload-time = "2025-01-06T17:57:04.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/59/57b3b38cf7a382855902b9d24266c283c29d977706438e6b7af62df74e2b/cmeel_octomap-1.10.0-5-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:042b4a21b5e5e19ee78a9a7db78e1b06fb8a287c832031788aec0d3fcabbfecd", size = 748832, upload-time = "2025-02-12T11:57:34.252Z" }, + { url = "https://files.pythonhosted.org/packages/55/8b/f5ec7676808a48c0185e216c0da700e34cb13ba233f13a4557a5ec56324a/cmeel_octomap-1.10.0-5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:79c15a0ece5ca3746170088ef2a377dfb3df8326fafde9bdba688852219758b9", size = 706924, upload-time = "2025-02-12T11:57:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/09/50/56de5a4d9f8ca58100146f16f42c4e2fbb49c0957bfe40d3fd2bc910afe4/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_17_i686.whl", hash = "sha256:e2923bf593ebdafed86b6f3890a122c62fbd9cc9f325d60dbecb72b6b60d78fb", size = 1073973, upload-time = "2025-02-12T11:57:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/8dddf5cdd31176288acd85cc8bf0262b7c3de81d5cb2cb33aa6646f44eb1/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9e6f9c826905e8de632e9df8cc20e59ce2eb5d1e0b368d8d4abbbc5c0829c1a", size = 1044533, upload-time = "2025-02-12T11:57:39.672Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/b85bd33bb05c9bb7e87b9ac8401793c12a80a6d594b3ca4bcb5e971a24b7/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b0b54fac180dce4f483afe7029c29cc55f6f2b21be8413e8e2275845b0c204d7", size = 1105199, upload-time = "2025-02-12T11:57:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/fe3360441159974ebdbb4c013a92ad0425d5f8bf414868d5161060e40660/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c8691e665bab7c12b6f51e6c5fbbb83ee6f91dce9d15d9d0387553950e7fb5ee", size = 1092962, upload-time = "2025-02-12T11:57:43.297Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/074166544cc0ce3a5d7844f97dfd13d1b3ec7bff6a6e2cfb18d66a671a7f/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:735c0ad84dacbbcc8c4237f127c57244c236b7d6c7500b5c45a4c225e19daac1", size = 1083321, upload-time = "2025-02-12T11:57:46.121Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a3/b19ea0d30837369091141b248936b0757ee17f58b809007399bad0b398e4/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f86a83f6bd60de290cd327f0374d525328369e76591e3ab2ad1bc0b183678c4", size = 1109207, upload-time = "2025-02-12T11:57:48.709Z" }, +] + +[[package]] +name = "cmeel-qhull" +version = "8.0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/dd/8d0bcfb18771b2ea02bf85dfbbc587c97b274496fb5419b72134eb69430b/cmeel_qhull-8.0.2.1.tar.gz", hash = "sha256:68e8d41d95f61830f2d460af1e4d760f0dbe4d46413d7c736f0ed701153ebe52", size = 1308055, upload-time = "2023-11-17T14:21:06.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b4/d72ebd5e9ee711b68ad466e7bd4c0edcb45b0c2c8a358fdcdb64b092666a/cmeel_qhull-8.0.2.1-0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:39f5183a6e026754c3c043239bac005bf1825240d72e1d8fdf090a0f3ea27307", size = 2804225, upload-time = "2023-11-17T14:15:39.958Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/4bfb8d51a09401cf740e66d10bdb388eacd7c73bae12ef78149cbbc93e83/cmeel_qhull-8.0.2.1-0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:f135c5a4f4c8ed53f061bc86b794aaca2c0c34761c9269c06b71329c9da56f82", size = 2972481, upload-time = "2023-11-17T14:20:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7c/74b5c781cbfc8e4a9bb73b71659cc595bc0163223fd700b18133dbcf2831/cmeel_qhull-8.0.2.1-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:17f519106df79aed9fc5ec92833d4958d132d23021f02a78a9564cdf83a36c7c", size = 3078962, upload-time = "2023-11-17T14:21:00.183Z" }, + { url = "https://files.pythonhosted.org/packages/b4/16/ef7b6201835ba2492753c9c91b266d047b6664507be42ec858e2b24673b5/cmeel_qhull-8.0.2.1-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:c513abafa40e2b8eb7cd3640e3f92d5391fbd6ec0f4182dbf9536934d8a8ea3e", size = 3194917, upload-time = "2023-11-17T14:21:01.879Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ae/200bdf257507e2c95d0656bf02278cd666d49f0a9e2e6d281ea76d7d085c/cmeel_qhull-8.0.2.1-0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:20a69cb34b6250aee1f018412989734c9ddcad6ff66717a7c5516fc19f55d5ff", size = 3290068, upload-time = "2023-11-17T14:21:03.828Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/de3fa6091ef58ab40f02653e777c8943acf7cec486184d6007885123571d/cmeel_qhull-8.0.2.1-1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b5d47b113c1cb8f519bc813cf015d0d01f8ce5b08912733a24a6018f7caa6e96", size = 2902499, upload-time = "2025-02-12T11:51:16.999Z" }, + { url = "https://files.pythonhosted.org/packages/05/0c/5e5d9a033c683eb272508ccf560c03ac6bf5d397b038fe05f896a2283eaf/cmeel_qhull-8.0.2.1-1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:33a0169f4ee37d093c450195b0ef73d4fe0d9d62abb7899ebe79f778b36e1f36", size = 2773563, upload-time = "2025-02-12T11:51:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/52/9b/00c73069348e60fbbdf6a5a10de046083f7d1ad36844958bbf12163ac688/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_17_i686.whl", hash = "sha256:a577e76ac94d128f2966b137ead9f088749513df63749728e2b588f4564b7fdf", size = 3228684, upload-time = "2025-02-12T11:51:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/81b8c88b444935a64d8c83b41e662f696c36dd5937c3ca687113ac4778d0/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fd0b2d4ce749b102c3cdead4588249befd34f1a660628f6bfc090ce942925aac", size = 3156051, upload-time = "2025-02-12T11:51:24.594Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c1/44874cd8bfc1e3f7cb15678c836c7a1d5537f34f5a727a0207e01f395598/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2371a7c80a14f3e874876359ae3e3094861f081fcdd7a03987c3e880d14e07b9", size = 3262508, upload-time = "2025-02-12T11:51:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/54/0e/425d9ce1f2a831025d39fa5b6479b856bd4d73614c9caa690ac72bbfca04/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:197c14c2006dbeba8f5a5771700a7afea72c1a441aab7cdeaaf10b4ed8c1137d", size = 3172646, upload-time = "2025-02-12T11:51:28.967Z" }, + { url = "https://files.pythonhosted.org/packages/00/c1/e973e287a7d793911b8e6497b17586e601a678f2379ba2c615f72bd76480/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:886d1be24b31842286ae42755af5c312a43a4199632826e4110185ec36dc5c6a", size = 3530837, upload-time = "2025-02-12T11:51:31.651Z" }, + { url = "https://files.pythonhosted.org/packages/fd/65/c6cd54f04b5fcaa4ec52f5b57692c1dcef812ff9ee86545e5607369d365e/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a49ce7f8492c9a8b49f930e34cce75b5e9b9843b015033dd0a25421441159fc", size = 3301908, upload-time = "2025-02-12T11:51:34.53Z" }, +] + +[[package]] +name = "cmeel-tinyxml2" +version = "11.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/96/4311533fee0a364bb605b585762f04c249f47857b33548a8ea837a7eb860/cmeel_tinyxml2-11.0.0.tar.gz", hash = "sha256:85d9c7680b3369af4c6b40a0dce70bbd84aa67832755622e57eb260cd95abe40", size = 645900, upload-time = "2026-05-21T11:49:32.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/f0/90c1640c53b623359d75ab1c70bdf19dc0afe82722bc5df57d09f8eaf83a/cmeel_tinyxml2-11.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b0bd974e549b8c444626671a8e645897603ebf5225734cbe04a9dd3461477754", size = 111719, upload-time = "2026-05-21T11:49:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/56/40/166447150a31bc3b794ffb493d5a634f67ffbc75dd8b4c46373701b7ef15/cmeel_tinyxml2-11.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a1406f408262c37ae7c4566b1d67801c4b10c4980903fb1ef0ba45fa4407072", size = 109146, upload-time = "2026-05-21T11:49:27.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ca/3cc665afe2d76999f15454bb3b2f7c05f0088ad7de35718648291a536fd9/cmeel_tinyxml2-11.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6f830007917c3e36f26b27d170ce84a619a62f46104d3cce435dff0125dd665f", size = 157109, upload-time = "2026-05-21T11:49:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/87/4e/dcc0d9756d93be734d824e2a570cc9ac68909a1d7d3b6fc87c2fb32726c0/cmeel_tinyxml2-11.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:18674156bd41f3993dc1d5199da04fa496674358daa6588090fb9f86c71917b0", size = 148825, upload-time = "2026-05-21T11:49:31.035Z" }, +] + +[[package]] +name = "cmeel-urdfdom" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-console-bridge" }, + { name = "cmeel-tinyxml2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/75/4e8aff079e98582aeeb8e752805081da0c2dea405e79bafeefb555defe9f/cmeel_urdfdom-6.0.0.tar.gz", hash = "sha256:65c0fdc6021300fc55b2d0c03ab64dedc328034a74e40498e671bc894bb1dcf7", size = 303688, upload-time = "2026-05-21T12:08:56.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/40/51ba667135f01631179eee1614557193f8453740f248302d1b8b7f9f693e/cmeel_urdfdom-6.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:53d55cebb137a6e4dac6c16fa53f2dc2b7b9b5cda644bd1637a5bb849cd96e52", size = 381501, upload-time = "2026-05-21T12:08:48.758Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/2b49a8c940fa75abc13df9842c14e577e6a82d5854b6d52597ce3bb04894/cmeel_urdfdom-6.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0ef424735bd30f4afa4d1b4ddca9b297498c43005ddd775c080e55f62e9e0466", size = 377159, upload-time = "2026-05-21T12:08:50.485Z" }, + { url = "https://files.pythonhosted.org/packages/db/ac/0efde3a48220b55707bafb6d2e2dcca562f99dcd5c2c15311f7696eeacce/cmeel_urdfdom-6.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0436f5230f1484c8e583284ef48c7291b230ada3dc5fb2937941f582e72409ec", size = 506000, upload-time = "2026-05-21T12:08:52.273Z" }, + { url = "https://files.pythonhosted.org/packages/32/d4/dfd617e598100e4e53ae3d228a968facff80bae53038fb18e2dccb1ab03a/cmeel_urdfdom-6.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7ab1be680a8ec866d5422c617b641d1f0e38774061df28b8b426fb26edce6337", size = 530049, upload-time = "2026-05-21T12:08:54.224Z" }, +] + +[[package]] +name = "cmeel-zlib" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/85/9c6b77d5c49b363b17ba974271d58730bd26cbe00b1c576596339bb624ea/cmeel_zlib-1.3.2.tar.gz", hash = "sha256:6e31f2956c334de9a7e19a4e4f8eed030ed8c8016c841ea57226ce8bed443712", size = 3200, upload-time = "2026-05-20T16:24:37.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e7/c60533fb6f638fe92d56b79edbcff70ed74a7f19b3ace981532efab5c3dd/cmeel_zlib-1.3.2-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:88a89882340391aa263ed1f7448fe0177e6fb9b93740cad381228437879f16a9", size = 1035696, upload-time = "2026-05-20T16:24:28.538Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/dfcc8b9c2989e7342aaac1781d147b06b31197337d1968029216be26ee43/cmeel_zlib-1.3.2-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:94a2f1adcc811617eab849b5771e608fa20e2fc44fb8cbd2e56a3e4d496461c1", size = 957505, upload-time = "2026-05-20T16:24:30.151Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e9/6a3d6732e23fcf7072973f9ca8251eaef3e6738e46d6846064dd92e13331/cmeel_zlib-1.3.2-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3b07fb7e28aaa917e6accb59a29dd0f5c1d272d42988f32ba2c232f5f455be4c", size = 1055975, upload-time = "2026-05-20T16:24:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/70/a4/d345605b6f8c9d665baa1dd2f839ad6aee0d771e174be12c81fdd53067ed/cmeel_zlib-1.3.2-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4658000c5531273d14ca8f0250abcd2a2ad85b336f10a273a77ecb38611a5f5a", size = 1052274, upload-time = "2026-05-20T16:24:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/83737c38cc106e1b4959393da7744d5180b9ab5eac0369fc6ba0ea6c6428/cmeel_zlib-1.3.2-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:06cbf60a361ca18e8a4c46a238149cebcaf955047a60830705e130aa397b5116", size = 1057248, upload-time = "2026-05-20T16:24:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/b378182b0f33ed9e9c091b3d1e8d86a6f2d69add107087b2e09adcb12137/cmeel_zlib-1.3.2-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2a3425a65a0adf0ddbb48d41204a0d9c13197bd693f7821d1450e8a969d77352", size = 1055409, upload-time = "2026-05-20T16:24:36.219Z" }, +] + +[[package]] +name = "coal" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-assimp" }, + { name = "cmeel-boost" }, + { name = "cmeel-octomap" }, + { name = "cmeel-qhull" }, + { name = "eigenpy" }, + { name = "libcoal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/14/8cb072a3e5fb85cac4d8d1234351ca3eb4ecf100b87901ab912ec7e5135e/coal-3.0.3.tar.gz", hash = "sha256:18660bde4021af496343646fa0a0d1bcf0be392f432641e772dc423be5075f64", size = 1464421, upload-time = "2026-05-21T12:30:20.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/4d/6af55fff20a0705f54ea313c2fe64b090e3e60e50fdcb5bfdb430a22e391/coal-3.0.3-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1e59370db33b4fb0e23da8f953b300b3eec591624803e3143d447be21f530b87", size = 1611149, upload-time = "2026-05-21T12:29:52.062Z" }, + { url = "https://files.pythonhosted.org/packages/9a/78/3323984a61b63515e1951e2f697d4250a55b54788bc65496e2824989d969/coal-3.0.3-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:22572a8f877488ab987f868ffd362c08f923470be6b1e4f9c86d260f110405fc", size = 1505271, upload-time = "2026-05-21T12:29:53.871Z" }, + { url = "https://files.pythonhosted.org/packages/1d/29/65550bafd2639b8152e6a0e63603e74f657761fc1dc5daa4a305b5ea9922/coal-3.0.3-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b8aafdf317161ba73858ed1a3e8e673c5a82bd04cef82a3b7ba953d6a8d1007d", size = 2218087, upload-time = "2026-05-21T12:29:55.361Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/e5859f9c03c6353e50ac8df218a6b9efb8de90f3fa1f3bd571bc6a3242cd/coal-3.0.3-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2c9a62cd52868ce295623e34e664084ee87b9bb6314df913952d288cbe8b3fe6", size = 2215823, upload-time = "2026-05-21T12:29:57.227Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/1c634b366c7ba991f3f11b73bfff5c8cfee0ee32911347f37a256bc1724c/coal-3.0.3-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fd9c9bb7d4de17afda5e59802fddddee4149bdf72312cd60d68403b2d5265e9b", size = 1626797, upload-time = "2026-05-21T12:29:59.079Z" }, + { url = "https://files.pythonhosted.org/packages/33/5a/6c9836db4747010fc31543336a6b7f9aea04b1a58b8fe09611daf5e965f4/coal-3.0.3-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d3c157a72d3c79a99bd6b59479ab5ca0efeed22b74e5d8e66dda827ce76e18e", size = 1516483, upload-time = "2026-05-21T12:30:00.967Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/8c7c7e83c83acdc5af8c553a2ca0b1b335132dccff4732edd6acd90a5a16/coal-3.0.3-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f9736086902db07b3b38b04917e7d3c9291d12340e5248c06b327ce6a6e2de49", size = 2190068, upload-time = "2026-05-21T12:30:02.692Z" }, + { url = "https://files.pythonhosted.org/packages/19/f1/25368a2fb63a18283b6732e2cdbe15bfb19df26b76f3721fd79729def54a/coal-3.0.3-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b98f45b92af8f6608a97d47f05abab861af13ba54a47c82dfc22c9f4e7754a18", size = 2201644, upload-time = "2026-05-21T12:30:04.333Z" }, + { url = "https://files.pythonhosted.org/packages/fb/37/69680aa3b0d6f1cf6309177326d625fe8afe49b440952ee36735c4e6be2b/coal-3.0.3-0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:cf489028d1123293fe4b0598fdd21ab2c0a7c6f69d8e61a0a849712b47aba885", size = 1626799, upload-time = "2026-05-21T12:30:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/7d/72/c3b7d84682868252e43f0dc3dfcec5a5d754a383fb20db17863a55d10f2b/coal-3.0.3-0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dc81cf507301d145b2c0085072effb3744bf3034669092f7d95c49d7318aba5", size = 1516481, upload-time = "2026-05-21T12:30:07.985Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d9/f0dd708b923368e4628fb760fe2ce2a3f2cd026e2effbad186c076fadb9c/coal-3.0.3-0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:691da7cd15683d23a28cdea81906a6e614cffbeab92523494ea493ede5d433e8", size = 2190073, upload-time = "2026-05-21T12:30:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/51/76/5ca02caa98c238bf336ce0387464669c29b6f57c3daab441f047a9e65cc1/coal-3.0.3-0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80cba8b0c3166a024deacfc801b0c6a06337d230af434c379f693887d5c4e458", size = 2201651, upload-time = "2026-05-21T12:30:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/aa/9b0a3560f86c586aa2ddde57c08f7b6d4c9dc622e82104300da5a1781e25/coal-3.0.3-0-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:5888efaa97a1d6160e07503c5463fa55180120d7219aec1cbe0a1efe1d84449b", size = 1628179, upload-time = "2026-05-21T12:30:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/a2/f90b450da22e69922c3ed36bb8e84376fac56a95d1c864ab477a5a77a951/coal-3.0.3-0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dfc85277f55495da5a15cf9a1d86530004c6225986803935cab6e6c90638002f", size = 1516891, upload-time = "2026-05-21T12:30:15.763Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/0172d8558f1231c9542b875854101616dfd4227bd540ad095e257596e8bf/coal-3.0.3-0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e3f46e2030a4a6f91704d5c8e310c80032e979e1d4bb491265a8beb4358a7998", size = 2197024, upload-time = "2026-05-21T12:30:17.562Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f2/f79c90ec7289f5f80481a1b60bde05a385d247f4a6269fc64b04e8714054/coal-3.0.3-0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4553a8e088fddf46c48229686e9b66df7e38d09bbd26fa32c411fb5cf86ac3f0", size = 2204048, upload-time = "2026-05-21T12:30:19.398Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "configargparse" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "dash" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "janus" }, + { name = "mcp" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/3c/0608ea83920ddbd27de3aeb60d48e56efb3390c2cfb44dabfd8dc25c0ba2/dash-4.3.0.tar.gz", hash = "sha256:1ec96641d5e3c06b7dfffad8241e345533d70327d96f722a8e13b827c287341b", size = 8095642, upload-time = "2026-06-19T14:56:03.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/ad/0a230dc6cd85bb38e7fe6acc9fbaeb19ddf16ababcb54038ae81128da591/dash-4.3.0-py3-none-any.whl", hash = "sha256:cbbe79a2bb1a5e46fbbbddc33f9f558419442c2d55cd12526078f489b5801cc0", size = 8445443, upload-time = "2026-06-19T14:55:59.024Z" }, +] + +[[package]] +name = "dbus-fast" +version = "5.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/84/dfb014de75a3a854dccaae1cce8f840e4312e3efc781768eedd60d25d9ef/dbus_fast-5.0.22-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846f9a6602b4383f989201f7851459fb225a8912cd24b38e63894748545c3040", size = 838836, upload-time = "2026-06-05T18:55:51.51Z" }, + { url = "https://files.pythonhosted.org/packages/70/c2/be41bcc678e97092d44ba22d09ce687f76c955b3367a7e6863377b1cfea5/dbus_fast-5.0.22-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886b43446b6fdc3986befbbb88db1365b14e49dd0a7edf84c2c67ac66c7160a4", size = 883163, upload-time = "2026-06-05T18:55:53Z" }, + { url = "https://files.pythonhosted.org/packages/17/cf/336c08f88fdd813a39fc1603a10a15aec67115e08a895e7c9840df54d4d7/dbus_fast-5.0.22-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a699fca957acc845ddb12b47f741dba23ce147fdb93583e0c7e7bad3e9b2355", size = 886852, upload-time = "2026-06-05T18:55:54.434Z" }, + { url = "https://files.pythonhosted.org/packages/04/f1/7c1aa53f25252a2317f21ad6e8eaa125246d2585ea4611f3f10a6feaeeb1/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a5b05fd4973862042e5bee2c5e8c5a15297e0b33a975bf25b44becf7bcb3618", size = 846497, upload-time = "2026-06-05T18:55:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/4c/32/a981ef2305f1bf41e538e02fa0cd69614f9043fd00ca965bf3044416c79b/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6c4dae5292a7924ec062815c34b49043d8386cd22e165f9fb4012de00997cdf1", size = 882275, upload-time = "2026-06-05T18:55:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/78800a172f4ca32e19a70a36e175f54831f33a497c9644f3a3fa4dce01ea/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7d90a52be79acbaef257f3a81d5b9b9dec40f1bad29429ac5c7802684fb9b84", size = 890566, upload-time = "2026-06-05T18:55:59.407Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/233b0bc13919474f70320bf389cb81ce02811956d6cf86c45e84679b63c3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca", size = 799464, upload-time = "2026-06-05T18:56:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/68/e9/77bc23a6f5aebfb8f2c34489795e8517aed7eca31738438e1a4c4a4891d3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", size = 852687, upload-time = "2026-06-05T18:56:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/56769d0936d1273d1801ef574ec426ccb3f61f4b0a7a0eeb9eb2b8ccafa5/dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c", size = 833814, upload-time = "2026-06-05T18:56:05.87Z" }, + { url = "https://files.pythonhosted.org/packages/06/ca/964f0d39a3be03b12a98f39519d34ad95b74360c7e3adf4cd4907dc25fd6/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b013437b66dc22b8d9aca5e0b0d46bf1980208a143409469fe482d9684a2a717", size = 806891, upload-time = "2026-06-05T18:56:07.623Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/57fe6ab509ad9da2e190498fa9c37f868e38ac521d940a9edb9ba6b6c657/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:855f15b7f7805171da2b82de1c317d01cfbb9fb8ac61fcc1e8dec54d8c69fab7", size = 830867, upload-time = "2026-06-05T18:56:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/fa81a6685c763ea488ad93228cb6e036adc9af6a560f4c31643691f4cfd8/dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5", size = 798472, upload-time = "2026-06-05T18:56:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/6b272e6df60be1aa4d575aa30220175a52c002a649c951d9950bfa3a72d6/dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb", size = 850312, upload-time = "2026-06-05T18:56:16.36Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/9045c3595ddcd4069e6b5d051df06bf11a2b022592f721c9acea1e0e4d22/dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08", size = 828366, upload-time = "2026-06-05T18:56:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/a7/78/ca6881442b8fa29edbe6d99bec4b535b0b2e2f423075d015ff5b719c4e2c/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979", size = 806036, upload-time = "2026-06-05T18:56:19.501Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c6/458728eb1caa26171e6a8ae1d0d99bd29aaeac67ad7824bbd95d7f854a41/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec", size = 828353, upload-time = "2026-06-05T18:56:21.346Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/830b1569264780210d44898e5b0d95cffe2830b952c2ee21ea481274cd81/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84", size = 857743, upload-time = "2026-06-05T18:56:22.9Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" }, + { url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" }, + { url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" }, +] + +[[package]] +name = "dimos" +version = "0.0.12" +source = { editable = "../../" } +dependencies = [ + { name = "annotation-protocol" }, + { name = "bleak" }, + { name = "cryptography" }, + { name = "dimos-lcm" }, + { name = "dimos-viewer" }, + { name = "llvmlite" }, + { name = "lz4" }, + { name = "numba" }, + { name = "numpy" }, + { name = "open3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "open3d-unofficial-arm", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "opencv-python" }, + { name = "pin" }, + { name = "plotext" }, + { name = "plum-dispatch" }, + { name = "protobuf" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "pyturbojpeg" }, + { name = "reactivex" }, + { name = "rerun-sdk" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sortedcontainers" }, + { name = "sqlite-vec" }, + { name = "structlog" }, + { name = "terminaltexteffects" }, + { name = "textual" }, + { name = "textual-serve" }, + { name = "toolz" }, + { name = "typer" }, + { name = "websocket-client" }, +] + +[package.metadata] +requires-dist = [ + { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'" }, + { name = "annotation-protocol", specifier = ">=1.4.0" }, + { name = "bleak", specifier = ">=3.0.2" }, + { name = "can-motor-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'", specifier = ">=0.0.2" }, + { name = "catkin-pkg", marker = "extra == 'grasp'", specifier = ">=1.1.0" }, + { name = "chromadb", marker = "extra == 'perception'", specifier = ">=1.0.0" }, + { name = "cryptography", specifier = ">=46.0.5" }, + { name = "cupy-cuda12x", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = "==13.6.0" }, + { name = "cyclonedds", marker = "extra == 'dds'", specifier = ">=0.10.5" }, + { name = "cyclonedds", marker = "extra == 'unitree-dds'", specifier = ">=0.10.5" }, + { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "grasp", "manipulation", "misc", "perception", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, + { name = "dimos", extras = ["agents", "web", "perception", "visualization"], marker = "extra == 'base'" }, + { name = "dimos", extras = ["base", "mapping"], marker = "extra == 'unitree'" }, + { name = "dimos", extras = ["unitree"], marker = "extra == 'unitree-dds'" }, + { name = "dimos-lcm", specifier = ">=0.1.3" }, + { name = "dimos-viewer", specifier = "==0.32.0a1" }, + { name = "dimos-viewer", marker = "extra == 'visualization'", specifier = "==0.32.0a1" }, + { name = "drake", marker = "platform_machine != 'aarch64' and sys_platform == 'darwin' and extra == 'manipulation'", specifier = "==1.45.0" }, + { name = "drake", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and extra == 'manipulation'", specifier = ">=1.40.0" }, + { name = "edgetam-dimos", marker = "extra == 'misc'" }, + { name = "einops", marker = "extra == 'perception'", specifier = ">=0.8.1" }, + { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.115.6" }, + { name = "faster-whisper", marker = "extra == 'agents'", specifier = ">=1.0.0" }, + { name = "ffmpeg-python", marker = "extra == 'web'" }, + { name = "gdown", marker = "extra == 'misc'", specifier = ">=5.2.2" }, + { name = "googlemaps", marker = "extra == 'misc'", specifier = ">=4.10.0" }, + { name = "gtsam-extended", marker = "extra == 'mapping'", specifier = ">=4.3a1.post1" }, + { name = "hydra-core", marker = "extra == 'perception'", specifier = ">=1.3.0" }, + { name = "ipykernel", marker = "extra == 'misc'" }, + { name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.6" }, + { name = "langchain", marker = "extra == 'agents'", specifier = ">=1.2.3,<2" }, + { name = "langchain-core", marker = "extra == 'agents'", specifier = ">=1.2.22,<2" }, + { name = "langchain-huggingface", marker = "extra == 'agents'", specifier = ">=1,<2" }, + { name = "langchain-ollama", marker = "extra == 'agents'", specifier = ">=1,<2" }, + { name = "langchain-openai", marker = "extra == 'agents'", specifier = ">=1,<2" }, + { name = "lap", marker = "extra == 'perception'", specifier = ">=0.5.12" }, + { name = "llvmlite", specifier = ">=0.42.0" }, + { name = "lz4", specifier = ">=4.4.5" }, + { name = "matplotlib", marker = "extra == 'grasp'", specifier = ">=3.7.1" }, + { name = "matplotlib", marker = "extra == 'manipulation'", specifier = ">=3.7.1" }, + { name = "mcap", marker = "extra == 'unitree-dds'", specifier = ">=1.2.0" }, + { name = "moondream", marker = "extra == 'perception'" }, + { name = "mujoco", marker = "extra == 'sim'", specifier = ">=3.3.4" }, + { name = "numba", specifier = ">=0.60.0" }, + { name = "numpy", specifier = ">=1.26.4" }, + { name = "ollama", marker = "extra == 'agents'", specifier = ">=0.6.0" }, + { name = "omegaconf", marker = "extra == 'perception'", specifier = ">=2.3.0" }, + { name = "onnxruntime", marker = "extra == 'cpu'" }, + { name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = ">=1.17.1" }, + { name = "open-clip-torch", marker = "extra == 'misc'", specifier = "==3.2.0" }, + { name = "open3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=0.18.0" }, + { name = "open3d-unofficial-arm", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = ">=0.19.0.post9" }, + { name = "openai", marker = "extra == 'agents'" }, + { name = "opencv-contrib-python", marker = "extra == 'apriltag'", specifier = "==4.10.0.84" }, + { name = "opencv-python" }, + { name = "pillow", marker = "extra == 'perception'" }, + { name = "pin", specifier = ">=3.3.0" }, + { name = "pin-pink", marker = "extra == 'manipulation'", specifier = ">=4.2.0" }, + { name = "piper-sdk", marker = "extra == 'manipulation'" }, + { name = "playground", marker = "extra == 'sim'", specifier = ">=0.0.5" }, + { name = "plotext", specifier = "==5.3.2" }, + { name = "plum-dispatch", specifier = "==2.5.7" }, + { name = "portal", marker = "extra == 'misc'" }, + { name = "protobuf", specifier = ">=6.33.5,<7" }, + { name = "psutil", specifier = ">=7.0.0" }, + { name = "pycollada", marker = "extra == 'manipulation'" }, + { name = "pydantic" }, + { name = "pydantic-settings", specifier = ">=2.11.0,<3" }, + { name = "pygame", marker = "extra == 'sim'", specifier = ">=2.6.1" }, + { name = "pymavlink", marker = "extra == 'drone'" }, + { name = "pyrealsense2-extended", marker = "sys_platform != 'darwin' and extra == 'manipulation'" }, + { name = "python-dotenv" }, + { name = "python-multipart", marker = "extra == 'misc'", specifier = ">=0.0.27" }, + { name = "pytorch-ignite", marker = "extra == 'grasp'" }, + { name = "pyturbojpeg", specifier = "==1.8.2" }, + { name = "pyyaml", marker = "extra == 'manipulation'", specifier = ">=6.0" }, + { name = "qpsolvers", extras = ["proxqp"], marker = "extra == 'manipulation'", specifier = ">=4.12.0" }, + { name = "reactivex" }, + { name = "reportlab", marker = "extra == 'apriltag'", specifier = ">=4.5.0" }, + { name = "rerun-sdk", specifier = "==0.32.0a1" }, + { name = "rerun-sdk", marker = "extra == 'visualization'", specifier = "==0.32.0a1" }, + { name = "roboplan", extras = ["manipulation"], marker = "extra == 'manipulation'", specifier = ">=0.0.100" }, + { name = "scipy", specifier = ">=1.15.1" }, + { name = "sortedcontainers", specifier = "==2.4.0" }, + { name = "sounddevice", marker = "extra == 'agents'" }, + { name = "soundfile", marker = "extra == 'web'" }, + { name = "sqlite-vec", specifier = ">=0.1.6" }, + { name = "sse-starlette", marker = "extra == 'web'", specifier = ">=2.2.1" }, + { name = "structlog", specifier = ">=25.5.0,<26" }, + { name = "tensorboard", marker = "extra == 'misc'", specifier = "==2.20.0" }, + { name = "terminaltexteffects", specifier = "==0.12.2" }, + { name = "textual", specifier = "==3.7.1" }, + { name = "textual-serve", specifier = ">=1.1.1,<2" }, + { name = "timm", marker = "extra == 'misc'", specifier = ">=1.0.15" }, + { name = "toolz", specifier = ">=1.1.0" }, + { name = "torch", marker = "extra == 'grasp'" }, + { name = "torchreid", marker = "extra == 'misc'", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], marker = "extra == 'perception'", specifier = ">=4.53.0,<4.54" }, + { name = "trimesh", marker = "extra == 'manipulation'" }, + { name = "typer", specifier = ">=0.19.2,<1" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0" }, + { name = "ultralytics", marker = "extra == 'perception'", specifier = ">=8.3.70" }, + { name = "unitree-sdk2py-dimos", marker = "extra == 'unitree-dds'", specifier = ">=1.0.2" }, + { name = "unitree-webrtc-connect", marker = "extra == 'unitree'", specifier = ">=2.1.2" }, + { name = "uvicorn", marker = "extra == 'web'", specifier = ">=0.34.0" }, + { name = "vgn", marker = "extra == 'grasp'", git = "https://github.com/ethz-asl/vgn.git?rev=d7af0622433f52ae88ebe81533f12b46b33e951a" }, + { name = "viser", extras = ["urdf"], marker = "extra == 'manipulation'", specifier = ">=1.0.29" }, + { name = "websocket-client", specifier = ">=1.8" }, + { name = "xacro", marker = "extra == 'manipulation'" }, + { name = "xarm-python-sdk", marker = "extra == 'manipulation'", specifier = ">=1.17.0" }, + { name = "xarm-python-sdk", marker = "extra == 'misc'", specifier = ">=1.17.0" }, +] +provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "grasp", "cpu", "cuda", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] + +[package.metadata.requires-dev] +autofix = [{ name = "ruff", specifier = "==0.14.3" }] +lint = [ + { name = "aiortc", specifier = ">=1.14.0" }, + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "ipython" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "moondream" }, + { name = "mypy", specifier = "==1.19.0" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "openai-whisper" }, + { name = "pandas-stubs", specifier = ">=2.3.2.250926,<3" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "python-can", specifier = ">=4" }, + { name = "python-socketio", specifier = ">=5.16.1" }, + { name = "ruff", specifier = "==0.14.3" }, + { name = "sounddevice", specifier = ">=0.5.5" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "trimesh", specifier = ">=4.12" }, + { name = "types-pyaudio" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915,<7" }, + { name = "types-reportlab", specifier = ">=4.5.0" }, + { name = "types-requests", specifier = ">=2.32.4.20260107,<3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "watchdog", specifier = ">=3.0.0" }, + { name = "xacro" }, +] +project-deps = [ + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "moondream" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "xacro" }, +] +tests = [ + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "coverage", specifier = ">=7.0" }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu"] }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "maturin", specifier = ">=1.7" }, + { name = "md-babel-py", specifier = ">=1.2.0" }, + { name = "moondream" }, + { name = "mujoco", specifier = ">=3.3.4" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "py-spy" }, + { name = "pygame", specifier = ">=2.6.1" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "pytest-env", specifier = "==1.1.5" }, + { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, + { name = "pytest-mock", specifier = "==3.15.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, + { name = "pytest-xdist", specifier = ">=3.5.0" }, + { name = "python-can", specifier = ">=4" }, + { name = "python-lsp-ruff", specifier = "==2.3.0" }, + { name = "python-lsp-server", extras = ["all"], specifier = "==1.14.0" }, + { name = "requests-mock", specifier = "==1.12.1" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "unitree-webrtc-connect", specifier = ">=2.1.2" }, + { name = "viser", extras = ["urdf"], marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=1.0.29" }, + { name = "watchdog", specifier = ">=3.0.0" }, + { name = "xacro" }, +] +tests-self-hosted = [ + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "coverage", specifier = ">=7.0" }, + { name = "dimos", extras = ["agents", "perception", "manipulation", "sim", "unitree", "misc"] }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu"] }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "maturin", specifier = ">=1.7" }, + { name = "mcap", specifier = ">=1.2.0" }, + { name = "md-babel-py", specifier = ">=1.2.0" }, + { name = "moondream" }, + { name = "mujoco", specifier = ">=3.3.4" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "py-spy" }, + { name = "pybind11", specifier = ">=2.12" }, + { name = "pygame", specifier = ">=2.6.1" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "pytest-env", specifier = "==1.1.5" }, + { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, + { name = "pytest-mock", specifier = "==3.15.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, + { name = "pytest-xdist", specifier = ">=3.5.0" }, + { name = "python-can", specifier = ">=4" }, + { name = "python-lsp-ruff", specifier = "==2.3.0" }, + { name = "python-lsp-server", extras = ["all"], specifier = "==1.14.0" }, + { name = "requests-mock", specifier = "==1.12.1" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "unitree-webrtc-connect", specifier = ">=2.1.2" }, + { name = "viser", extras = ["urdf"], marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=1.0.29" }, + { name = "watchdog", specifier = ">=3.0.0" }, + { name = "xacro" }, +] + +[[package]] +name = "dimos-gpd-worker-demo" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dimos" }, + { name = "gpd" }, +] + +[package.metadata] +requires-dist = [ + { name = "dimos", editable = "../../" }, + { name = "gpd", git = "https://github.com/TomCC7/gpd.git?rev=c088d8ae2f7965b067e9a12b3c0dacdbe9da924a" }, +] + +[[package]] +name = "dimos-lcm" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "foxglove-websocket" }, + { name = "lcm-dimos-fork" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b2/4cdd2bce665ab633313b5d371e0a63fcae2cf77a934a2a9bf820db88a540/dimos_lcm-0.1.3.tar.gz", hash = "sha256:5f7dbd3055f299823bc0e450c59583ad5a2d093c182deec8a86073853881bb09", size = 405688, upload-time = "2026-06-03T07:20:20.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/19/2d8babf544993508359922ea59db2280d42efa0d5a80d4d0cae22ce40d67/dimos_lcm-0.1.3-py3-none-any.whl", hash = "sha256:63317225a0b4ab0f05e4b656f3f78ac9ba4e914998da2c689ca839cbbd83d57d", size = 1714087, upload-time = "2026-06-03T07:20:22.689Z" }, +] + +[[package]] +name = "dimos-viewer" +version = "0.32.0a1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/37/394f74ad8981c4666d9014189f3fb154e7f796c408d770d94f52ed4b1ccb/dimos_viewer-0.32.0a1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:86f2dc0f5db732b28ce5d9f13749a12b26f3b3c891e68bae8fcb4c065f08ddf5", size = 38326731, upload-time = "2026-05-19T14:55:05.95Z" }, + { url = "https://files.pythonhosted.org/packages/35/0d/677674fd9a8ac73689642cd7c4bb1b177df9d096f39a74dadd1420ffc221/dimos_viewer-0.32.0a1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f22176cea73d6e898230432de19ca07772a7c1293171ad86dae0b7e54e1fbbf5", size = 42253900, upload-time = "2026-05-19T14:55:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/0c32f2a5e0908dcab3d8b652704d68124cbe2d74ca418561e503fa216cff/dimos_viewer-0.32.0a1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f4a4b6e1aa83b50421a7133b22c27c0924b2f984a242d30273087f66928af5d8", size = 44794937, upload-time = "2026-05-19T14:55:12.888Z" }, + { url = "https://files.pythonhosted.org/packages/3f/51/efcf47f154b5940d09d6e480f3f75f00a927396d9be1f5b2523b1ff9d62e/dimos_viewer-0.32.0a1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4cc88e26567955ce964a250c7250cd3d52e99f330d892420699f0580de8c4f3", size = 38326735, upload-time = "2026-05-19T14:55:16.477Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3e/7564e12f24f4678ba5e311a538eda0a2e46add9afcd58d08984e66bc280c/dimos_viewer-0.32.0a1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:68354192271a201a71491245a7b444b0e4047b74c9d2496a395010356917c0c3", size = 42253910, upload-time = "2026-05-19T14:55:20.232Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4b/d97f13e3585582c79037cd24952ac99de593a30c300ddcc8c83d9cab1128/dimos_viewer-0.32.0a1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ebdd64ce20d73e9d6e0256ef099a3d544c14b9b227aa005ba8c362052889b3c4", size = 44794790, upload-time = "2026-05-19T14:55:24.211Z" }, +] + +[[package]] +name = "eigenpy" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/04/15aadcaf4141c791217724c3edc103cd66c5444987cb897a2e7112bb94ee/eigenpy-3.13.0.tar.gz", hash = "sha256:49d3cb46b85696baf5445ac707b48aade07c21579c188dfc1c8ad2f92e05fab5", size = 229750, upload-time = "2026-05-21T11:01:28.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/fe/20fccfbb855a2fc94cf1b05eac2634abd77f47126c3e0d3e0353665b2f2b/eigenpy-3.13.0-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbcf85629d25d822db935ab9d5415218cff0717d620f87e1f3e7b86f1357cfd4", size = 5440358, upload-time = "2026-05-21T11:00:53.805Z" }, + { url = "https://files.pythonhosted.org/packages/ee/52/fa7433226e3af2437b70bb2d8357173cae74abe3ead4240c9b136a794043/eigenpy-3.13.0-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e4d51aa0980fd7b4d47acc9c2ac9c4e9bbb6a6d1e2149ba2426292f2972276e", size = 4251073, upload-time = "2026-05-21T11:00:55.921Z" }, + { url = "https://files.pythonhosted.org/packages/66/1e/6c3e45fbd37812faf9343c70a7661f376c123259f9e43e6f2b4fc4211f5c/eigenpy-3.13.0-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:aabe2f552e6ecae5381446bc03ba612469a10a06dfc471bbf2f194e0edcd6974", size = 6228917, upload-time = "2026-05-21T11:00:58.128Z" }, + { url = "https://files.pythonhosted.org/packages/83/a3/79326cff7ab8a55cd9024eee4ddf3082ac9f7be2c978fc5f5b056affc968/eigenpy-3.13.0-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:bca70df5845a3598f1ddd77a3ce46ef5eb28228c85e362e4f75115c6cb80dcff", size = 6064372, upload-time = "2026-05-21T11:01:00.376Z" }, + { url = "https://files.pythonhosted.org/packages/07/03/bfc7a04ef67dd330f7cee8c984d6d101457a19dd9612e5a15306417b62c2/eigenpy-3.13.0-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2203a3073e54a9d55b9766b2d8fdaa6eae3e733742ac5862ca8afbd4e55021ae", size = 5485488, upload-time = "2026-05-21T11:01:02.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/61/cb30323bce5cec4a5a496e41972e12110638dbdba94f2d23814dd82f0eb4/eigenpy-3.13.0-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a844167b3dcb6157cd782fe8cd0396153548eccdcfe81e95803f2499f306ed18", size = 4261367, upload-time = "2026-05-21T11:01:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/fb/90/28a30554ff1a6794a2ef91ffc021737796441d617acd649efecf6b307cce/eigenpy-3.13.0-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5524d36fe0705d0097a42e0d18e4eb3f49dc1e28be301200ab7af7d58b9ae5f", size = 6230829, upload-time = "2026-05-21T11:01:07.631Z" }, + { url = "https://files.pythonhosted.org/packages/5f/39/f3f15daffdcecc31612135ae0f77603ce6ba88c9fcc1c6a5f1e53b2eb0f9/eigenpy-3.13.0-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cde8ed4080da67e0c037ba1453c8d33c74465714ce5196781b640dcc398579a5", size = 6068314, upload-time = "2026-05-21T11:01:10.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/0e2aadbae4d70bfbc4db0196be399bc949c246391c6bbcbd674fc1aa42af/eigenpy-3.13.0-0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:0818e204858c0345e38dd18c0f0aa20082f176ce26e57cc72d9b0d1ef0df5c4e", size = 5485486, upload-time = "2026-05-21T11:01:12.154Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/3cdd0a7e0ee83ac6a914e527b0e5adf18b383ea8132ad31eab00143666ec/eigenpy-3.13.0-0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc6de29029d6ccfcf30dae12e272727982585dd67fd923d7c55fa8690948a9a1", size = 4261376, upload-time = "2026-05-21T11:01:14.157Z" }, + { url = "https://files.pythonhosted.org/packages/14/12/414f3c31d51928e9c7160e3db196f79419ca81fa01dec6283c64a67bc776/eigenpy-3.13.0-0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:caff6dbf5f063dee474ae2aba0bcdd3cb6b18566397339e4ee6386e7c5adedb1", size = 6230831, upload-time = "2026-05-21T11:01:16.372Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/439c4f16f8b6bc341137dc5ee75d896a9d032cd8dc9eedb0464eaad81f20/eigenpy-3.13.0-0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:041df9317cd2a88b2c77a7f459bba8359015540b3bb704118c150a9dea1c3d9b", size = 6068320, upload-time = "2026-05-21T11:01:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/84/fb/199ef9f6b78b8e51bfc27acc29de8610d648c37704da1b0ed261cf250581/eigenpy-3.13.0-0-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:2f99da9a17768340a0b0cff5cb3ab39458fcd7b44b8e7ab5c749f84751b9c9b4", size = 5488262, upload-time = "2026-05-21T11:01:20.253Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0d/a657817bcd640f6e806e2fddb80b99a512669225d5b578d766a59fdfda09/eigenpy-3.13.0-0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0d8459fcde1fa501ee13587ef85faa07229e1c9030f392253f4f4c7fb31c5585", size = 4271730, upload-time = "2026-05-21T11:01:22.198Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5d/4d096eb4b6ec78de2c59320454608db462de8c1b4f0bc73f59b6e8ea5543/eigenpy-3.13.0-0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:cbff5fb6902e2de7b2436864149141b47246babc2ab6ee035b5666662e495552", size = 6238730, upload-time = "2026-05-21T11:01:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/db/57/2dacd8dbdce012e8ce41329c07352803220a219cb108a3f482a680696249/eigenpy-3.13.0-0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:facffa9dde8a8d123de8e6766a3611362d2969433d8a9e83377b0c03c099044c", size = 6086683, upload-time = "2026-05-21T11:01:26.888Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "foxglove-websocket" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/b5/df32ac550eb0df9000ed78d872eb19738edecfd88f47fe08588d5066f317/foxglove_websocket-0.1.4.tar.gz", hash = "sha256:2ec8936982e478d103dd90268a572599fc0cce45a4ab95490d5bc31f7c8a8af8", size = 16616, upload-time = "2025-07-14T20:26:28.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/73/3a3e6cb864ddf98800a9236ad497d32e5b50eb1682ac659f7d669d92faec/foxglove_websocket-0.1.4-py3-none-any.whl", hash = "sha256:772e24e2c98bdfc704df53f7177c8ff5bab0abc4dac59a91463aca16debdd83a", size = 14392, upload-time = "2025-07-14T20:26:26.899Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "gpd" +version = "0.1.0" +source = { git = "https://github.com/TomCC7/gpd.git?rev=c088d8ae2f7965b067e9a12b3c0dacdbe9da924a#c088d8ae2f7965b067e9a12b3c0dacdbe9da924a" } +dependencies = [ + { name = "numpy" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "janus" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/7f/69884b6618be4baf6ebcacc716ee8680a842428a19f403db6d1c0bb990aa/janus-2.0.0.tar.gz", hash = "sha256:0970f38e0e725400496c834a368a67ee551dc3b5ad0a257e132f5b46f2e77770", size = 22910, upload-time = "2024-12-13T12:59:08.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/34/65604740edcb20e1bda6a890348ed7d282e7dd23aa00401cbe36fd0edbd9/janus-2.0.0-py3-none-any.whl", hash = "sha256:7e6449d34eab04cd016befbd7d8c0d8acaaaab67cb59e076a69149f9031745f9", size = 12161, upload-time = "2024-12-13T12:59:06.106Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "lcm-dimos-fork" +version = "1.5.2.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/53/26e5c821858fff27ed9bf5be6e848a63db89bcd6a0c4cb8546dce3396a7d/lcm_dimos_fork-1.5.2.post1.tar.gz", hash = "sha256:3ec3703605ea1ea2f82ab327d73eb7fddf3775c1365e4a112314e11f389ad72f", size = 5538346, upload-time = "2026-06-03T07:09:31.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/50/8c640088b7722d11d6c1acec133afa44e344cbb5c25236f3ca9b8bdb0d4e/lcm_dimos_fork-1.5.2.post1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e36c02538a5f4f3b7c4bf9cf7f130001deb4087e6c4ab2b87a92b63b5800eaf5", size = 3501233, upload-time = "2026-06-03T07:09:18.794Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e3/c2f4e3d67129caf105f9dade2c0a77755ea1d11e0bf13eb6041e8bf69954/lcm_dimos_fork-1.5.2.post1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ca37f8bda7733084e3a840ca71df139413fa9d7187ef39eb31425301fc8effaa", size = 2861890, upload-time = "2026-06-03T07:09:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9d/f19698bc9888c0e7d1244b02e20cb4f4fa83617f046760e7f2f21799008b/lcm_dimos_fork-1.5.2.post1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3ad82221708a7510edd7ea484733efe40c82bdb553816679b4cc0d8967d1b9be", size = 2882420, upload-time = "2026-06-03T07:09:09.346Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f8/0e300ecb1f5719a42adaadd009c27b6543576172e55f5070c8eec3e6093e/lcm_dimos_fork-1.5.2.post1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:75bd1fc3d6365bb47d1ee95c0b5dd267ca5516b393fcbc393e8c35bfb22c34b2", size = 3501253, upload-time = "2026-06-03T07:09:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bf/1673d451d032cff65214692e2431858800e4baecee32f68a9ccfe19919b8/lcm_dimos_fork-1.5.2.post1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c3c4827d7d9d618270b70c564dacbc142dbce5dc03566510ba698bfebf2830b0", size = 2861845, upload-time = "2026-06-03T07:09:14.2Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/a31d94a20c3ac5d6fd3fa89b2de66dbf6a7f01551fdff29d9a5fbf02cba6/lcm_dimos_fork-1.5.2.post1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:700294567efed85e96f2ae435b7c55d8702e15f3d06154f56216c1be5c85b322", size = 2882574, upload-time = "2026-06-03T07:09:23.407Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b1/db2c874eca7cec08d2e98b5797dd5e295e86611ae581e81d84f86e532ac4/lcm_dimos_fork-1.5.2.post1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:73f4097dab2a1f5834733b7a8b4568ab6d9b071b59df72e21141aca19c38e895", size = 3501200, upload-time = "2026-06-03T07:09:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d5/cd5292b61b8aad43769f9133fbdf7c0011b9d45d38e54de1747d60fedc1a/lcm_dimos_fork-1.5.2.post1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e1991cbd43969f1165edca5e087644ddd7544a4681229348ebed93ce680e85cd", size = 2861937, upload-time = "2026-06-03T07:09:36.409Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e7/09c44788185bd4ee887639b6cea7340116d3e8a7fecddc9b61f79f9aac7c/lcm_dimos_fork-1.5.2.post1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f6bf33c71795a2d5a4d98e28d41ae6c56d066916fa5e2c1b4e8af5a38f0fdb25", size = 2882429, upload-time = "2026-06-03T07:09:06.555Z" }, +] + +[[package]] +name = "libcoal" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-assimp" }, + { name = "cmeel-boost" }, + { name = "cmeel-octomap" }, + { name = "cmeel-qhull" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/44/d59b10af87165def48bf55fcb3f0591274a39322d48cb92d608d17ed86b5/libcoal-3.0.3.tar.gz", hash = "sha256:fd0f887682c73ef2d429caa09721993acb596b9e529c4f37761fe34faa7675ed", size = 1464501, upload-time = "2026-05-21T08:41:47.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/35/853c33314d1dcb50ff8c1d832cbe1533dd4d0cf6be685b96bbb1d5219d35/libcoal-3.0.3-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c1b3a0c4d10a690c61fb47eb42aee32bad827846bc7413238364ab340b1faf84", size = 1683905, upload-time = "2026-05-21T08:41:39.006Z" }, + { url = "https://files.pythonhosted.org/packages/14/bb/c2288420bd28f563ff6557f17d330fa07d6e5c5198b130da7efee9e53b86/libcoal-3.0.3-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c09ff80ff486f00bdcde1fbde14a143933d20c2c46f8567d6f95817339242fbf", size = 1484193, upload-time = "2026-05-21T08:41:40.917Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/d8ef12e6a3a21d40d146ccea38fc29e547d884ebd9f531105902b5408092/libcoal-3.0.3-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:18e203ed53da8b96b192a74c4c041fe33f98b0249bb7ec27a3b67397ce1ecc4d", size = 2257010, upload-time = "2026-05-21T08:41:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a1/062d7d444eb9260244cdced41384933f8cdf5b383ab9ed2810d048720bd3/libcoal-3.0.3-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:28fa1473d80728994f7275b8c8797858959ab77a643f07f2222feb6de155bb8e", size = 2285044, upload-time = "2026-05-21T08:41:45.03Z" }, +] + +[[package]] +name = "libpinocchio" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, + { name = "cmeel-urdfdom" }, + { name = "libcoal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/c9/6c1d428cc8dc10eda45726ac4898bd9ce659366c934e2c37e8e5ba3ae330/libpinocchio-4.0.0.tar.gz", hash = "sha256:425a4ea81fa046238ddb7d8e0c6109bff69a80dd945f84dc3813243b59e3d548", size = 4418773, upload-time = "2026-05-21T13:30:05.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/f4/53010f1cf23def730d774b9a99d33ffe76da1bdb63ba2e877432d7f652bf/libpinocchio-4.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c497750021075ca95715d478314970a82d7b9d0ce0e9138800378db8cbd4ac3d", size = 3677932, upload-time = "2026-05-21T13:29:58.474Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/150954766542d00e9c3ede6ea918f9ec852e7657637e5dc0c842ff059339/libpinocchio-4.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6d617a36b0a0c60774b321f1662d86aeb7161cf88581ae5c2ca4665d765ea16", size = 3116217, upload-time = "2026-05-21T13:30:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/45/65/150b7d6b3986a63d20f95c0f066fadb586fed21ab027bf4cbe8a70230474/libpinocchio-4.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:43d0ee81449126c6f1b184111cac74d83e8903a57401b8d37bde53ed946cf24e", size = 3639624, upload-time = "2026-05-21T13:30:02.244Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b4/9292b05386d51975ffe69ebcc9dee351ee8018eb0dd1acbcdf48352b2b63/libpinocchio-4.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a5b7b8d968f1fe8a57e01fb05464521c09a8d368d2b4e482bee25db018bbd197", size = 3804644, upload-time = "2026-05-21T13:30:04.052Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] +plugins = [ + { name = "mdit-py-plugins" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a2/78f662f1b18968531f67d3fcde1b7ea8496920bacd4f16ddb5b79d112e46/matplotlib-3.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f857524b442f0f36e641868ce2171aafa88cb0bc0644f4e1d8a5df9b32649fef", size = 9436261, upload-time = "2026-06-12T02:27:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/5e/92/044f1de43901310202f4c79acf4f141be53b2ca8d8380e2fcefb3d523a75/matplotlib-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57baa92fdc82948ed716eae6d2579d4d6f40965cd8d2f416755b4a72580a3233", size = 9264669, upload-time = "2026-06-12T02:27:37.413Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/f0b4f9ba7ec14a7af8151f3ad71ecfe3561e6ba38cfab1db3681ba4ca112/matplotlib-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630eee0e67d35cce2019a0e670719f4816e3b86aff0fa72729f6c69786fceb45", size = 10021076, upload-time = "2026-06-12T02:27:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/4d679c6dcd594a156542080ac907ddccf7b09ca11655c4b28eca8e9ee5da/matplotlib-3.11.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5106c444d0bf966eee2853548c03772af4ab7199118e086c62fbac8ccb07c055", size = 10828999, upload-time = "2026-06-12T02:27:42.433Z" }, + { url = "https://files.pythonhosted.org/packages/07/74/0a3683802037d8cd013144d77c247219b47f2aabace6fdde74faa12bacf7/matplotlib-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d7aea652b58e686444079be3376ef546bffa1eee9b9bb9c472b9fcf6cf410d3", size = 10913103, upload-time = "2026-06-12T02:27:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/970fcbf381e82ec66fdf5da8ea76e2e9240f61a24011ce9fd1d42c37ac2d/matplotlib-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:70a5b3e9a5dab708c0f039709ae7c68d5b4d254e291ef76492cdba230c8bb5e4", size = 9310945, upload-time = "2026-06-12T02:27:46.867Z" }, + { url = "https://files.pythonhosted.org/packages/14/4e/6e7cfed23611265ded53806852343b5c59339e506e84c474a9b5afc3b249/matplotlib-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d68266213e73823ac3be90615bab0cf31f88851e114cdb1dd25dacf3b01e1a7", size = 8999304, upload-time = "2026-06-12T02:27:48.798Z" }, + { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532, upload-time = "2026-06-12T02:28:06.769Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760, upload-time = "2026-06-12T02:28:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623, upload-time = "2026-06-12T02:28:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372, upload-time = "2026-06-12T02:28:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099, upload-time = "2026-06-12T02:28:16.383Z" }, + { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727, upload-time = "2026-06-12T02:28:18.581Z" }, + { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506, upload-time = "2026-06-12T02:28:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838, upload-time = "2026-06-12T02:28:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298, upload-time = "2026-06-12T02:28:24.796Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491, upload-time = "2026-06-12T02:28:27.141Z" }, + { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059, upload-time = "2026-06-12T02:28:29.222Z" }, + { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576, upload-time = "2026-06-12T02:28:31.66Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519, upload-time = "2026-06-12T02:28:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456, upload-time = "2026-06-12T02:28:35.878Z" }, + { url = "https://files.pythonhosted.org/packages/57/52/a94102ac99eb78e2fe9b826674f9ef9ee23327110ea6ab4776c1b4eb6209/matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79", size = 9452137, upload-time = "2026-06-12T02:28:37.93Z" }, + { url = "https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3", size = 9281514, upload-time = "2026-06-12T02:28:40.028Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2d/4e1240ea82ee197dfb3851e71f71c87eeeb975f1753b56a0588e4e80739a/matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9", size = 10843005, upload-time = "2026-06-12T02:28:42.39Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430", size = 11127459, upload-time = "2026-06-12T02:28:44.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/795c405aa7560443a3b01309424cde4a1113b85c90b8a63417444a749617/matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba", size = 10925160, upload-time = "2026-06-12T02:28:46.564Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2", size = 9485186, upload-time = "2026-06-12T02:28:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c0/396478ee7cf2091d182db8b4a8695f6a37f1ddb978989cf9dbb84cd5c123/matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d", size = 9160349, upload-time = "2026-06-12T02:28:51.382Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/1c3bd51bb2b34eaacdcf3c3d859dbb357f952fc8020c617dc118ad7c9e38/matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847", size = 9500921, upload-time = "2026-06-12T02:28:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/4d861d0121840cb1a3fd4a10deb211efd6fccd481ed23e553f31f4f4da4a/matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e", size = 9332190, upload-time = "2026-06-12T02:28:55.623Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/22f6bc35711a0b5639a784e74e653e77c86210bd4304449dd399a482f74e/matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0", size = 10854181, upload-time = "2026-06-12T02:28:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/9a9eaca731a2939589da520f0ebe8fd8753d0f51fca98c7d20af6dbe261a/matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb", size = 11137715, upload-time = "2026-06-12T02:29:00.555Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f9/9b030b6088354acb0296871bb624b25befc1c42509d3c6cd17420c83a5b8/matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9", size = 10939427, upload-time = "2026-06-12T02:29:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/59/94/6b273eaee4ee250863567d100865da61a5c1527fa67f527b7ed22e0dd29c/matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6", size = 9535809, upload-time = "2026-06-12T02:29:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/60/95/1d36bddf2b7e2692c1540e78a6e5bc88bc1496b137e3e35a611f91b65ac3/matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159", size = 9209226, upload-time = "2026-06-12T02:29:07.033Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c2/f5da6cd37ed6871f5c9b3c0507ddb69f14d6c36fac4541e4e0c60cb8cdfc/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62", size = 9434094, upload-time = "2026-06-12T02:29:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/f8/07/56f66906e0f87a0c6d0d0acbd34dbc9432b1931d8f26ef618bd6f92932a9/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd", size = 9262183, upload-time = "2026-06-12T02:29:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" }, +] + +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/b3/650500c2eab4534d98e9166f4298e0f3c69c742afdf24e6eabccd1f16ad8/numba-0.65.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7020d74b19cdb8cff16506542fdd510756e28c5e7f3bd0b7f574f0f42272fcd9", size = 2680563, upload-time = "2026-04-24T02:02:18.414Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/0615dbedb98f5b32a35a53290fbdc6e22306968109278d7e58df82d7a9f6/numba-0.65.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f80ed83774b5173abd6581cd8d2165d1d38e13d2e5c8155c0c0b421784745420", size = 3745018, upload-time = "2026-04-24T02:02:20.252Z" }, + { url = "https://files.pythonhosted.org/packages/49/aa/4361698f35bf63bff67dfe6c90493731177f48ede954f77b0588731537bc/numba-0.65.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ed425a43b0a5f9772f2f4e2dd0bbd12eabecae1af0b24efcfd4e053f012aac6", size = 3450962, upload-time = "2026-04-24T02:02:22.449Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9a/af61ec03b3116c161fd7a06b9e8a265729a8718458333e8ffbb06d9a3978/numba-0.65.1-cp311-cp311-win_amd64.whl", hash = "sha256:df40a5028a975b9ea66f6a2a3f7abbdbd541a863070e34ed367aff21141248e4", size = 2747417, upload-time = "2026-04-24T02:02:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, + { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "open3d" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "addict" }, + { name = "configargparse" }, + { name = "dash" }, + { name = "flask" }, + { name = "matplotlib" }, + { name = "nbformat" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pyquaternion" }, + { name = "pyyaml" }, + { name = "scikit-learn" }, + { name = "tqdm" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/37/8d1746fcb58c37a9bd868fdca9a36c25b3c277bd764b7146419d11d2a58d/open3d-0.19.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:117702467bfb1602e9ae0ee5e2c7bcf573ebcd227b36a26f9f08425b52c89929", size = 103098641, upload-time = "2025-01-08T07:26:12.371Z" }, + { url = "https://files.pythonhosted.org/packages/bc/50/339bae21d0078cc3d3735e8eaf493a353a17dcc95d76bcefaa8edcf723d3/open3d-0.19.0-cp311-cp311-manylinux_2_31_x86_64.whl", hash = "sha256:678017392f6cc64a19d83afeb5329ffe8196893de2432f4c258eaaa819421bb5", size = 447683616, upload-time = "2025-01-08T07:22:48.098Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3c/358f1cc5b034dc6a785408b7aa7643e503229d890bcbc830cda9fce778b1/open3d-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:02091c309708f09da1167d2ea475e05d19f5e81dff025145f3afd9373cbba61f", size = 69151111, upload-time = "2025-01-08T07:27:22.662Z" }, + { url = "https://files.pythonhosted.org/packages/37/c5/286c605e087e72ad83eab130451ce13b768caa4374d926dc735edc20da5a/open3d-0.19.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:9e4a8d29443ba4c83010d199d56c96bf553dd970d3351692ab271759cbe2d7ac", size = 103202754, upload-time = "2025-01-08T07:26:27.169Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/3723e5ade77c234a1650db11cbe59fe25c4f5af6c224f8ea22ff088bb36a/open3d-0.19.0-cp312-cp312-manylinux_2_31_x86_64.whl", hash = "sha256:01e4590dc2209040292ebe509542fbf2bf869ea60bcd9be7a3fe77b65bad3192", size = 447665185, upload-time = "2025-01-08T07:23:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/35a6e0a35aa72420e75dc28d54b24beaff79bcad150423e47c67d2ad8773/open3d-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:665839837e1d3a62524804c31031462c3b548a2b6ed55214e6deb91522844f97", size = 69169961, upload-time = "2025-01-08T07:27:35.392Z" }, +] + +[[package]] +name = "open3d-unofficial-arm" +version = "0.19.0.post9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "dash" }, + { name = "flask" }, + { name = "nbformat" }, + { name = "numpy" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/f9/edcfaa213800ea278804402baa65693840bc7a323b3de8a31c54ce4e42c8/open3d_unofficial_arm-0.19.0.post9.tar.gz", hash = "sha256:ee300bd557f04750db6e47ccb6c6867c6dd6cfc04169dddeb92505da9ea739ef", size = 5327, upload-time = "2026-04-16T21:21:11.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/63/657916febb68b6d65539ea57bc50c9e82622a1bbb5af527950d7b3f49644/open3d_unofficial_arm-0.19.0.post9-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:3b407104a000f0e44b27730e84ffef85e25ce6fb9bb09e5368c462449eb4e6f0", size = 48233468, upload-time = "2026-04-16T21:23:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9c/863a42c39d2f3d8dbed617e624b3bac04225a56be193b999009954ec0cac/open3d_unofficial_arm-0.19.0.post9-cp312-cp312-manylinux_2_31_aarch64.whl", hash = "sha256:e104c90aa35ee7c4e2470abb225522f39cff5ab2f3cf9297680be9367e846e56", size = 47305232, upload-time = "2026-04-16T21:24:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/4f2ee6cadddf2bd87dcb6f0f43fdf5acca6b24c550c695c20e328567e61a/open3d_unofficial_arm-0.19.0.post9-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:4f7c325cad5c589363723967b1275a2b6fc3776ebaee4476a44f4baef5754c24", size = 48221802, upload-time = "2026-04-16T21:24:24.015Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/7424cb61263be183284bbfcdd3c3ea6b806966135bc36df56e00f540f12a/open3d_unofficial_arm-0.19.0.post9-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:1c8c7132896a0c44a72aeaf9818e4c06b1e194da7a3b3dc640ddaa1fd2b5be3e", size = 48223501, upload-time = "2026-04-16T21:24:37.007Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/fd/e0194474c71dbfba82e744f66945c274f15b667acd5f8c117b12555fb91e/pandas-3.0.4.tar.gz", hash = "sha256:62f6062586d159663825f06e70ef49cd1572d45824cb63a9559f3ffd1d0d2a20", size = 4658146, upload-time = "2026-06-28T15:31:51.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/be/f23b2369adf0fd241820778e3d534e940cf052e6cd325fee9b508726d26e/pandas-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:87d6be6820c5c2b3c41d30f2c8387aac10e842af7d43dd9c3c22f2ce0a4c4176", size = 10409998, upload-time = "2026-06-28T15:29:45.196Z" }, + { url = "https://files.pythonhosted.org/packages/79/c0/902b30ae918f5482016f6151f7e4621b71f43200f4b20f7a7fa162c74130/pandas-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dce7eca7e5adc4cc79bc28435e6111474772c14c11f4a742ea0041e23fff7d73", size = 10041669, upload-time = "2026-06-28T15:29:49.492Z" }, + { url = "https://files.pythonhosted.org/packages/58/54/2b287f9edfcbeecfd5ae9372827738f47f84130c4be73c9069c04e29bf1a/pandas-3.0.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14d274e00885373c879042dd3bd3dd27bfea6edef993f23644ed8a20468a7471", size = 10598978, upload-time = "2026-06-28T15:29:52.113Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/94e1bf5c6b3a635b7d8dc496af11840489322522a53e842477fdbcd8b08a/pandas-3.0.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c423500b3f0118c2d02b5a76ade4c57f3a13b9c9cecf04599bf983ba247cebaa", size = 11119683, upload-time = "2026-06-28T15:29:54.912Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/d8c41d1807a09c51170e2666c956e07d881eaaac1ecdc942d6f8662a9d1e/pandas-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaa9760a86a77a2586807a72f64748b312b95c37135ae9932cd65fe859507377", size = 11612249, upload-time = "2026-06-28T15:29:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/e62c97b2234c89bd541ff3cc19a019c4399ca5d0af35c6a35a6bf5b41a3a/pandas-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9c0eb4036cbf4110af16c5593191c51c1803752c7645f8f74b3a922a1027f42", size = 12170521, upload-time = "2026-06-28T15:30:01.695Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b8/f5f6caf9a6c643296a92e88922c53e9932101e00403517c45ca3704b47ab/pandas-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:a79a6401beed48057b9101d7e722d3d0af80e570b578438da6c7de4a10cc3a29", size = 9868871, upload-time = "2026-06-28T15:30:04.754Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f8/22d6f9575a918c0a5c2c57414fd4ff07765b8bce1bce34bb7b6a886c6d86/pandas-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:dc827bff97d448ced1a8d9b4055486cadd97b6ac52c3eb3e1dd154f49d869be1", size = 9117450, upload-time = "2026-06-28T15:30:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/69/7d/37fce6b1f4537218af19ab71ca2814e0794c4a1d4e412026afdc1988b5cb/pandas-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0f22242de864ca997028520e28b5dcdd440443ad239395bdabcb6124ed009067", size = 10422311, upload-time = "2026-06-28T15:30:12.192Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ce/3b280464d1c6faba9080435cd54f23c244ea79228f3508913f58753235dc/pandas-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bef5bf689a9f0d21ca83436f74c061ba52f426a689d2f8028e2e3d9ac0a8d05", size = 10061739, upload-time = "2026-06-28T15:30:15.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/50/927a39cdf93bf541b56390c32a58ae64f70f2748b8f2a88576ce8e4b6d7b/pandas-3.0.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9624cceb273c8f97d16c518f0cbf362d12edbdbb4ed46712eb2def2f7ed7de1", size = 10287537, upload-time = "2026-06-28T15:30:18.181Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/6898384737b5e32dd72067843bea661661ff32325da91f978347ba563cf2/pandas-3.0.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a477e4b7f3d18d63b5131e7a5faea3f2f8d7153f493cb145e552ba52c904ab2e", size = 10793447, upload-time = "2026-06-28T15:30:21.038Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8d/1424ca0d99ea192e84b17d1547463ed7f9cd628c9c395b770aded58a7efc/pandas-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fa604623167246500d962d109df5c5b953955afb637f0dd14d9500ab191dbce7", size = 11309351, upload-time = "2026-06-28T15:30:23.987Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f0/4f5aa9df56b378434b056afc22e0a6f4ed2b69006f61e61cd4b4397a87cd/pandas-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:833232f7a694cb841a2fd8b309ca655e24dcac2ae3e1959efed97d573499b389", size = 11859582, upload-time = "2026-06-28T15:30:27.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/97c3be4b0bbe82ed31d78d32659637d1ad0f2ad50b524a0851a068734f9c/pandas-3.0.4-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:25d9ba5ad021e7bd50c674f528e31f15dc22e28c9dfa50bd4cdbdd3a4f5f1902", size = 7115248, upload-time = "2026-06-28T15:30:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/66/93/503f381c106a9e6b4717356cc4805cc68b5af64b97f273590bf294cedd54/pandas-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:6b6797a68ccb1391ec7b7873681b23317dd4cfbd45228aadc0b837fdff02170d", size = 9670611, upload-time = "2026-06-28T15:30:32.776Z" }, + { url = "https://files.pythonhosted.org/packages/92/7d/9d7efe0c18d059d9cf0cd0f56f55c6aa584f8c506bcd5776ae097f195eaf/pandas-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:b3543e828bbd8d6ebe98b6a4268f57edc6bd945561fd08abfd29021bd5dc23ff", size = 8966047, upload-time = "2026-06-28T15:30:35.665Z" }, + { url = "https://files.pythonhosted.org/packages/dc/df/9febd45b3a643876260a4f53c6c1c7e30894e9e9f7d3a484b97d4ccc61fb/pandas-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ab8b23bf3ea0fe4337d115389d800896583854694b4c0b2de08e19c81f70140f", size = 10443559, upload-time = "2026-06-28T15:30:38.67Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e2/081b445177716989e6320c49b45c67b0b5ea75d71a327843826e380e1875/pandas-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcf15e8c8cbe4d1bb7b9c3f243ddb96123bbeb4585a40bad4c22dd673620a3c0", size = 10073663, upload-time = "2026-06-28T15:30:41.329Z" }, + { url = "https://files.pythonhosted.org/packages/7a/76/60d39cd44c42995cb92cc627138a429533098bb4e9f4268dfef53de24e77/pandas-3.0.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7ddfd571e26846b568d6e08065002046b54ffd4e81c51a61520f3040c677431", size = 10236941, upload-time = "2026-06-28T15:30:44.034Z" }, + { url = "https://files.pythonhosted.org/packages/50/68/7abd718ed1b373e47684bb788dec0b547ed218a41a15e0b04d05b81d1779/pandas-3.0.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afa70bd5a24cedf0c983d888f7d48c3ca1bc5fc095d38a3e6277e85f9bd4ab3", size = 10762008, upload-time = "2026-06-28T15:30:46.904Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0c/99ee1b43b23cbeb69585aebaa08575fa1bf3e8e0c5f3bb27f5148ae7d8bb/pandas-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba4acbe620c3888ffc561d278ba5a67f30fa9d2aba92052fbee9c951938110fc", size = 11258245, upload-time = "2026-06-28T15:30:50.438Z" }, + { url = "https://files.pythonhosted.org/packages/23/74/e6dd260c2d65fe8a5eed5b2bd7756f9393ccaefef12ea683fb05b7c15b9c/pandas-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26451dd26dcfa2e9f5172cb9c6d8213cebfc9130fc7a1844e11e642ffa458b54", size = 11820489, upload-time = "2026-06-28T15:30:53.539Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e7/8eedac9e7443651a4400d2ec8ebfb4be3be43cc3ec8ac3d5fbdbf7f0e149/pandas-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:63468a898ba7b54280fc6db21833cf99b9b62b2b54b5d5d10b5ab813ce100fbf", size = 9650344, upload-time = "2026-06-28T15:30:56.716Z" }, + { url = "https://files.pythonhosted.org/packages/fc/36/5f00ada639c67062720dc813a72d6dcca8eb86bc55620d2bb17dcf284073/pandas-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:5f9538b9d13b974a3a7d40c23b8d66f41114ceffe6c586f38efb8ed71d778caa", size = 8958077, upload-time = "2026-06-28T15:30:59.462Z" }, + { url = "https://files.pythonhosted.org/packages/da/9d/b70e2d6fd65f497a03922d0a0019a2b9ec7a40374fbd2f2cb6a697109806/pandas-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:45751e5d456788ca891167c6456fb5e373ad72aa82a949c9635b63e215940181", size = 10515604, upload-time = "2026-06-28T15:31:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fc/a514a959eeb0a8ad0294747df5ff1c95bf364aa2f2c533d6c2a8c0720bf4/pandas-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5c912d80243e4563934f34acc41cfad1a08d7af1b40597492cc6b5e6ba311404", size = 10169825, upload-time = "2026-06-28T15:31:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/72/f8/11da3c5c7a39ab436bda99a1bf2ff5ab3b6addb91155b3fc5d068e733940/pandas-3.0.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccdf354cddecbf0d763ab5a21d0d698440154c5ed6f2c43643c5f528e5c184a7", size = 10384438, upload-time = "2026-06-28T15:31:07.949Z" }, + { url = "https://files.pythonhosted.org/packages/3e/64/01d6352e2f108045e628680f1dd0a11276489beeae580c2c9d8a74df390b/pandas-3.0.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadb8f48ada5de3feaa735577af15f0874cd5498d6875cf17989d97d4bc2e926", size = 10795673, upload-time = "2026-06-28T15:31:11.331Z" }, + { url = "https://files.pythonhosted.org/packages/47/1f/c4319dc17cc88b7a90780ecaad15a484ab8ddee32466093a7ff6fcf8fdb1/pandas-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf867a8fe99499762626adbda73759b0378add0d13a58044ee668ba2d5df92f9", size = 11394840, upload-time = "2026-06-28T15:31:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c4/5660f3f5bebfee4bcdee8c13212806f326ba9d1dbdf7f408c7766f279faa/pandas-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:58a0b88e5f6c46974ba110c2cef6b8714aaea57f9f26d55ab5361de662b28165", size = 11877357, upload-time = "2026-06-28T15:31:17.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/92/a16e08eebab6183817757adf6783f644e2061fa14e5905ced79d362cbf36/pandas-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:2a32675e6e7641effa300e58040609107f6e976bb0783b0264556358df64db58", size = 9806280, upload-time = "2026-06-28T15:31:20.496Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/7d0810b2ba48fd3b015f82e5f907b2b757f40727d86481a2bea22123e454/pandas-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:337be3b93ca7e0db3ec25edc769ebb74a8702427edeb345ee6ebe3e3080c6350", size = 9125456, upload-time = "2026-06-28T15:31:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/78/e6/32800039b35eaa4eeb2da0182dd13d2cc205ce21e13e860c0e855f9f64e8/pandas-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ca9c1332eb5b69027fa1a73125e5b35fae26aea8c64a716a1924261070b0b859", size = 10938203, upload-time = "2026-06-28T15:31:26.903Z" }, + { url = "https://files.pythonhosted.org/packages/67/16/eff8bc63f22c8e881d31fe25a09542eea3bf8142ccf595974fc76f28a373/pandas-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1ae9e815bb61e0a85227aec3db22ba5502a4192df4918c115a772960e6ba606", size = 10586746, upload-time = "2026-06-28T15:31:29.836Z" }, + { url = "https://files.pythonhosted.org/packages/0b/03/6b413ed392ce8d7e90af963123bc62ccb6dd8d889ecf8951b7d1c1dddd86/pandas-3.0.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f482730bc2ec12b53c6205190deb195275495b4871bc1a2036beb91a7c97aee", size = 10240823, upload-time = "2026-06-28T15:31:32.879Z" }, + { url = "https://files.pythonhosted.org/packages/67/26/7992ed1748beb166a68d667e6651a03309f7e08a88a0520a7a381e06e21e/pandas-3.0.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5065f02eb94b947ff2610bba2b0c1a74fd67ca10fde0686b12adfc36e61d11d3", size = 10658793, upload-time = "2026-06-28T15:31:36.404Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c92b5ea088632476b831200c54de4740531a6777c8f650ab3c3cec5e7bf5/pandas-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b1a5e49e5fa13d002e1b237af4ab57f91f535cc6f6ca9225a366b2ce6241d99", size = 11274714, upload-time = "2026-06-28T15:31:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0c/149cfc617640575a28c878f2d26f20899f4ef206b08a7338a83eb592f297/pandas-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f660473c1e7a7ea7155214f89047b83ac0db6ebe7fab4d5a463f881307162d8c", size = 11729898, upload-time = "2026-06-28T15:31:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/fb/4a/5f1e5b5784ecd4eab4ad52b2025f3e828c289d49c063eac64b400619bb07/pandas-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:453c9e65d0ea8f1ff00d0d5dd2a2612df9ead3ac1baaf55b81e18a01a7a56846", size = 10201594, upload-time = "2026-06-28T15:31:45.917Z" }, + { url = "https://files.pythonhosted.org/packages/05/f3/40a59a8e1abf01b13be5102ece560dc30bb7e85ed06b3e78442c445cb028/pandas-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:02a8c70e5a304947a802551b8ddb38fe83d9c528395a361609fddcccb7cf8003", size = 9387615, upload-time = "2026-06-28T15:31:48.694Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[package]] +name = "pin" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, + { name = "cmeel-urdfdom" }, + { name = "coal" }, + { name = "libpinocchio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/40/72fa61797578c9121824645398497d2561ec29eb1d9cb88ca43973435834/pin-4.0.0.tar.gz", hash = "sha256:5bdf7dbf76437f797e8ea4f9a0e286f33e2af3fde38d67efc34832da2edced66", size = 4417957, upload-time = "2026-05-21T17:27:18.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/59/551829f6dd81dcb84ea681f107085afae99a45db954083aac1cbaff8a8fa/pin-4.0.0-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c123b3627af8fbfff255954bb115517cc5a8f3c572765ec259cf67b8eb6d2c7", size = 7397256, upload-time = "2026-05-21T17:26:44.54Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/d91b71ddaa46e0a3990514aa9488631acf8527a26a76f6328cd662dabdeb/pin-4.0.0-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:322b6c15b8992e01b7ea5f5de91e39dec54022b2c862dd9a0766ac446d7dfc47", size = 7134478, upload-time = "2026-05-21T17:26:46.59Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0e/ed1a5716caf890c4b02481552d60f12df4a587716af453d7d4f8ad9b7198/pin-4.0.0-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4a81cc911ca16911fffcedcacc9c159f1961691cb1fa65cadef6559515aea136", size = 9979416, upload-time = "2026-05-21T17:26:48.822Z" }, + { url = "https://files.pythonhosted.org/packages/0d/75/cc1bc40bace8ee7382f417f4dacceabcdcd20b716fa66a5de029f98a4715/pin-4.0.0-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:62ce8dfcc92f43c0f765d9e145a21da914f9b2adb9ee724e87a870f03995fe2d", size = 10207826, upload-time = "2026-05-21T17:26:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/91/ab/f2f7e8b5eba232adba310dafbf78c1a8722fde77a5c5defab88693bfe90e/pin-4.0.0-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8be5f63b34b7f5da40e6f1f4554ff2f0606c425f89347b0f25f5ce0d3d42a56b", size = 7469991, upload-time = "2026-05-21T17:26:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/88/ca/8cd3f29177213abf9c9714039e1e916694edc935b6644fc33bb037b358d3/pin-4.0.0-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1abe8107a14d7507983324e3866ed043653d846029148299d1965d0ce32d31e7", size = 7185247, upload-time = "2026-05-21T17:26:55.148Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/76c375d6d0e044094686c566f2e14d39a86f35e1286e546be6d05a82cf6e/pin-4.0.0-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1d9b4df62e71e1be729ceadc344aeda65dac3581caa4368f2e4377a991b66329", size = 9880446, upload-time = "2026-05-21T17:26:57.183Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/5d3400107ea829ff9002786ea483d0ac6590caaa8ba427c2d5f588a1a3cd/pin-4.0.0-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8ec1a71de6d8ed6de9e567c5c3dff22a044a3c3671590ee887d930763b267ee3", size = 10117715, upload-time = "2026-05-21T17:26:59.353Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b8/78abb97f8016eab014c95ee0450320b49b688aa4f1e5c8d05abc8bbe7cf2/pin-4.0.0-0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:335c339c9ab0347a5f97cc7dc8cb8223848eaf129170bc99ada8406fe9bfae6b", size = 7469985, upload-time = "2026-05-21T17:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/e1/66/9387b259bede7dbdbf88271cd2a8d3da16e52ada868df34066b38f26ab8f/pin-4.0.0-0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1cbf4d19cd0b2ec36586c8eca3a1dfcf505dbf6460a4c1871ee413016b504837", size = 7185240, upload-time = "2026-05-21T17:27:03.646Z" }, + { url = "https://files.pythonhosted.org/packages/67/9e/66be1862119f9d9937adb4d58acac97197c1f6907f9c33eab013a5ae42f1/pin-4.0.0-0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3007fe31ce670b970cb9a4ea020f68f1840d579d171cadb708c460e893691106", size = 9880447, upload-time = "2026-05-21T17:27:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/8a/de/0fefb66a60cb584e51c86769031977279dbdd177b08c07b481b3ae2bdf82/pin-4.0.0-0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e0d099fdd4118560d927a31559773acba94ac687259944348d9c513efd50a4b0", size = 10117711, upload-time = "2026-05-21T17:27:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f9/354d6a34bdc7a5e8a48ce9e57af129adafc2d440fd380b99189767c46e42/pin-4.0.0-0-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:acd837481bd5c5e2cb738077545f1640e67a9df7a402dac53f5d8b7ffde5ef74", size = 7488077, upload-time = "2026-05-21T17:27:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/ec/12/136f1d7a37e5a9c02469a413c0b5a60e42422360d53353fe8fec807c3620/pin-4.0.0-0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c19322c5107b3bc0a1a393b9333401c6e0202499c0d1e06f79c58db0c4daa595", size = 7207864, upload-time = "2026-05-21T17:27:11.846Z" }, + { url = "https://files.pythonhosted.org/packages/5f/38/e9c0dec21d4b83f37336eda3238c820a66469bddda865291ab80c48ce492/pin-4.0.0-0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2ecac51096eba64eccf491415af4a3ea2667e8161563cff9a2dfc768282c7847", size = 9918160, upload-time = "2026-05-21T17:27:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ca/e783bf65a76aa2d36a7cdccda7b77bba4e611efa44d870ec6032bfb9caf7/pin-4.0.0-0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f3e5f897beee5ec009f368d221fd706078bd56d3ddc8edbb07fe646451f5db5a", size = 10130188, upload-time = "2026-05-21T17:27:15.776Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + +[[package]] +name = "plotly" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/fd/d72c292d78aadb93d1a9bcd76bf3c678271040c7cf10abe5788b33040a39/plotly-6.8.0.tar.gz", hash = "sha256:e088e7ddc68d4f70e3d66659224727a45296d71d2b8284181862d3d8f1f0d88f", size = 6915161, upload-time = "2026-06-03T18:33:40.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl", hash = "sha256:13c5c4a0f70b74cab1913eda0de49b826df5931708eb6f9c3010040614700ec8", size = 9902055, upload-time = "2026-06-03T18:33:34.26Z" }, +] + +[[package]] +name = "plum-dispatch" +version = "2.5.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/46/ab3928e864b0a88a8ae6987b3da3b7ae32fe0a610264f33272139275dab5/plum_dispatch-2.5.7.tar.gz", hash = "sha256:a7908ad5563b93f387e3817eb0412ad40cfbad04bc61d869cf7a76cd58a3895d", size = 35452, upload-time = "2025-01-17T20:07:31.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/31/21609a9be48e877bc33b089a7f495c853215def5aeb9564a31c210d9d769/plum_dispatch-2.5.7-py3-none-any.whl", hash = "sha256:06471782eea0b3798c1e79dca2af2165bafcfa5eb595540b514ddd81053b1ede", size = 42612, upload-time = "2025-01-17T20:07:26.461Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/91/c76f3c5e8e80c7047e43c4c05b3e6fda9a7cefad5aae85487007674c966c/pyobjc_framework_corebluetooth-12.2.1.tar.gz", hash = "sha256:7dbb285295097205bebbcb11f55161e5faa02111108fb7b17536176e31971eb0", size = 37568, upload-time = "2026-06-19T16:20:12.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/ad/de57d9060e4c5e9ca1a9bdd5b6bd1bdb73b198c0c53953cac445d9b3a84b/pyobjc_framework_corebluetooth-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f11df7052fa2d0524a9dadbc9c578fd3b8f3a350431edfa4ffa9e0a74b6faa32", size = 13195, upload-time = "2026-06-19T16:08:26.961Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c4/7938016860850e28c001dc9b7c653352c43f7aebfffc6d3c5fd087281f22/pyobjc_framework_corebluetooth-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2f8d2ed65c0e98ba044b6a7fc2f9a290a5c579a28d33a57c642f8617ccbcca0f", size = 13217, upload-time = "2026-06-19T16:08:27.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/79/890a53ed45c1006eedcf60627b7d661c8696e5367723ceb25cc6a0216b30/pyobjc_framework_corebluetooth-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:30a26eef36c250fc14e73335641e24f764b32b7e42bae945a5d8a1c2347040b5", size = 13235, upload-time = "2026-06-19T16:08:28.727Z" }, + { url = "https://files.pythonhosted.org/packages/22/7a/40ffc3be8e31b1eb1f8f5eb2a58ef832287fb1ea6b3c452dc8b25b9e064b/pyobjc_framework_corebluetooth-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:756933b9ce6160a986c8877ab667659fa2d8e15aff28d0981b5284fbdd1ea735", size = 13416, upload-time = "2026-06-19T16:08:29.643Z" }, + { url = "https://files.pythonhosted.org/packages/30/ff/6f3b0bb3110ec82dbedaea47de151bd688980f5aadc634ef0cd236fdbd16/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2a2e6d56f51e4ca3e3b9766ef34150a9a7ce5f0cf4f9ee879ec10923af58e97e", size = 13223, upload-time = "2026-06-19T16:08:30.672Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/4e12660569219e4a68186ae9709b85278d3ebaf8d2f8e1c826a7337f4f7a/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50d7e4245dbdc8789dcc1f11fca2e633aa126a298b09db62f8216531fe107ee2", size = 13414, upload-time = "2026-06-19T16:08:31.679Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4c/976ae9bcce3615af806e3c314ea9caa3faacf11ec44f00b1a149559c6cb3/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c8d126c56b71c25218be186930a1b41739f83e931726a52e3298beeb170c5e5b", size = 13222, upload-time = "2026-06-19T16:08:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/99/be/44bb648a6b5c8aec79138bf562dab9eef414016ee31f37066bf81d809ae9/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:81023518feb75e9b2b676b28198955c51ae00548cf23c73c524c7101263b68db", size = 13424, upload-time = "2026-06-19T16:08:33.336Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/3f/561653aff3f19873457c95c053f0298da517be89fdfc0ec35115ed5b7030/pyobjc_framework_libdispatch-12.2.1.tar.gz", hash = "sha256:0d24eda41c6c258135077f60d410e704bc7b5a67adcb2ca463918896c7363795", size = 40336, upload-time = "2026-06-19T16:20:56.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/b0/dc263ed1cee54badccaf60f061de1e25bea95504984401a22bee274b5f59/pyobjc_framework_libdispatch-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e295775c76eace23f60e53ef74b0f79429c665f91a870e4665c4b9887466efa", size = 20488, upload-time = "2026-06-19T16:12:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8f/42cfa987c07a2b5ce8c236a42b0fb388b8807dac72c25e004cd4905ea9a3/pyobjc_framework_libdispatch-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8f41b5021ff70bc51220a79b41ebd1eacb55fe3ceb67448594f30e491a2c42a5", size = 15656, upload-time = "2026-06-19T16:12:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/cfe97f1beb13f5b7ca5c4348158c2de886d58ffba5be09a9376557f7d6f6/pyobjc_framework_libdispatch-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9c0ebf99520083bf17c007a544c100056a0d4ae5c346fb89e1bdfe6d041f16f2", size = 15679, upload-time = "2026-06-19T16:12:51.28Z" }, + { url = "https://files.pythonhosted.org/packages/d7/de/ef6b51bc72fe5ac1df80c34b1b13a97d0922ddd6bc5d3ecf5ead1557bf34/pyobjc_framework_libdispatch-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3f56fd71b963a0b6e440ed2f0ea2fb635221758b7eb908ba38f96f5144b83ca3", size = 15946, upload-time = "2026-06-19T16:12:52.098Z" }, + { url = "https://files.pythonhosted.org/packages/42/87/5b4a6c8580f2a486daf4b0d14a2356c47abfda401b329e71e46ac9b5460c/pyobjc_framework_libdispatch-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:999bad9a2c9198c837ba8f57a3ca9f05b4fc4bf7b69318baaa266dd2ab2fc8f7", size = 15699, upload-time = "2026-06-19T16:12:52.917Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/68cff50cb37a6ea311b7e805105ea13c33043762772714bc25d269c0730d/pyobjc_framework_libdispatch-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3fc93971f40d9757995c1e4b995a1614a468a5178be27e3d81e9bdc0b5e3cf75", size = 15981, upload-time = "2026-06-19T16:12:53.845Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5d/1f48e023555817f1271e86849ebd092743fc8bd292b6f82e87aba5df6122/pyobjc_framework_libdispatch-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:26a096c81c8cf272f4f1bb8f6c4b7565e005d273d218b53b83d925da5292633f", size = 15719, upload-time = "2026-06-19T16:12:54.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/44/b45c32851a3bcd367c62804c23aa55ea7918af6e16fddf1df23f5d7ca750/pyobjc_framework_libdispatch-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:82c6512fb4985f3bcd6b60b0cff79a4b483b44d1d2e5405010e34dd4b60aa01b", size = 16009, upload-time = "2026-06-19T16:12:55.513Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyquaternion" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/3d092aa20efaedacb89c3221a92c6491be5b28f618a2c36b52b53e7446c2/pyquaternion-0.9.9.tar.gz", hash = "sha256:b1f61af219cb2fe966b5fb79a192124f2e63a3f7a777ac3cadf2957b1a81bea8", size = 15530, upload-time = "2020-10-05T01:31:30.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b3/d8482e8cacc8ea15a356efea13d22ce1c5914a9ee36622ba250523240bf2/pyquaternion-0.9.9-py3-none-any.whl", hash = "sha256:e65f6e3f7b1fdf1a9e23f82434334a1ae84f14223eee835190cd2e841f8172ec", size = 14361, upload-time = "2020-10-05T01:31:37.575Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyturbojpeg" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/e8/0cbd6e4f086a3b9261b2539ab5ddb1e3ba0c94d45b47832594d4b4607586/PyTurboJPEG-1.8.2.tar.gz", hash = "sha256:b7d9625bbb2121b923228fc70d0c2b010b386687501f5b50acec4501222e152b", size = 12694, upload-time = "2025-06-22T07:26:45.861Z" } + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "reactivex" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/af/38a4b62468e4c5bd50acf511d86fe62e65a466aa6abb55b1d59a4a9e57f3/reactivex-4.1.0.tar.gz", hash = "sha256:c7499e3c802bccaa20839b3e17355a7d939573fded3f38ba3d4796278a169a3d", size = 113482, upload-time = "2025-11-05T21:44:24.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/9e/3c2f5d3abb6c5d82f7696e1e3c69b7279049e928596ce82ed25ca97a08f3/reactivex-4.1.0-py3-none-any.whl", hash = "sha256:485750ec8d9b34bcc8ff4318971d234dc4f595058a1b4435a74aefef4b2bc9bd", size = 218588, upload-time = "2025-11-05T21:44:23.015Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rerun-sdk" +version = "0.32.0a1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pyarrow" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e0/5d92c02d6ca63b5692e72ad3c5b89e32d44c3a6a99a74be3cb9a9d2c9741/rerun_sdk-0.32.0a1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:704e031c4ed3eb83837ce37b73741082ab669a2b4c88348a08ba61e3ba6d8656", size = 123843351, upload-time = "2026-04-29T18:59:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/1daa4751a281bdb6cda3473229b2746779093ffaba70dd868a3074ef3392/rerun_sdk-0.32.0a1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0a821b30f287dabbf694aa1715e4dad0e31990cf4b612757c895f096885512e9", size = 133447478, upload-time = "2026-04-29T18:59:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/84170e6722a09294b57d578fc1d01266c6e78408a544876f5271ecfa66a9/rerun_sdk-0.32.0a1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8045beb820372e092a6cf100fb87091fa804168e5cf8a0c29f97cdee56b134d8", size = 137724590, upload-time = "2026-04-29T18:59:55.238Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2f/09845dcdf14047cc8118566572628c533f57f6a232ee0b3fd61283b153e8/rerun_sdk-0.32.0a1-cp310-abi3-win_amd64.whl", hash = "sha256:1d6f8c3d50699ae075551a62e971730870a685cb74189b550a348561f37f9fe5", size = 118766780, upload-time = "2026-04-29T19:00:00.121Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "terminaltexteffects" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/92/0eb3f0ad206bf449b7db75f061202dce27d8cb90e598ce3c7d32c0bd80b9/terminaltexteffects-0.12.2.tar.gz", hash = "sha256:4a5eef341d538743e7ac4341cd74d47afc9d0345acdad330ed03fd0a72e41f5f", size = 164321, upload-time = "2025-10-20T20:58:26.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/93/a588ab8b15ceeef23042aa52660fb4891a0e955e92cd3aa97dcafe621720/terminaltexteffects-0.12.2-py3-none-any.whl", hash = "sha256:4b986034094007aa9a31cb1bd16d5d8fcac9755fb6a5da8f74ee7b70c0fa2d63", size = 189344, upload-time = "2025-10-20T20:58:24.425Z" }, +] + +[[package]] +name = "textual" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify", "plugins"] }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/83/c99c252c3fad2f7010ceb476a31af042eec71da441ffeef75bb590bc2e9e/textual-3.7.1.tar.gz", hash = "sha256:a76ba0c8a6c194ef24fd5c3681ebfddca55e7127c064a014128c84fbd7f5d271", size = 1604038, upload-time = "2025-07-09T09:04:45.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f1/8929fcce6dc983f7a260d0f3ddd2a69b74ba17383dbe57a7e0a9e085e8be/textual-3.7.1-py3-none-any.whl", hash = "sha256:ab5d153f4f65e77017977fa150d0376409e0acf5f1d2e25e2e4ab9de6c0d61ff", size = 691472, upload-time = "2025-07-09T09:04:43.626Z" }, +] + +[[package]] +name = "textual-serve" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-jinja2" }, + { name = "jinja2" }, + { name = "rich" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/7e/62fecc552853ec6a178cb1faa2d6f73b34d5512924770e7b08b58ff14148/textual_serve-1.1.3.tar.gz", hash = "sha256:f8f636ae2f5fd651b79d965473c3e9383d3521cdf896f9bc289709185da3f683", size = 448340, upload-time = "2025-11-01T16:22:36.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fe/108e7773349d500cf363328c3d0b7123e03feda51e310a3a5b136ac8ca71/textual_serve-1.1.3-py3-none-any.whl", hash = "sha256:207a472bc6604e725b1adab4ab8bf12f4c4dc25b04eea31e4d04731d8bf30f18", size = 447339, upload-time = "2025-11-01T16:22:35.209Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/8d/d7ae0e07cd85c7768de76e8578261854f2af72bd3a8a527bb675e8ae0eda/winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022", size = 210798, upload-time = "2025-06-06T06:44:04.775Z" }, + { url = "https://files.pythonhosted.org/packages/ac/66/d05f6e6c0517654734e7f87fa1f0fbc965add9f27cc36b524d96331ab3d8/winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c", size = 242032, upload-time = "2025-06-06T06:44:06.103Z" }, + { url = "https://files.pythonhosted.org/packages/39/a5/760c8396110f6d3e4c417752da1a2bf3b89e0998329c2f10afc717ef6291/winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130", size = 415659, upload-time = "2025-06-06T06:44:07.007Z" }, + { url = "https://files.pythonhosted.org/packages/d3/54/3dd06f2341fab6abb06588a16b30e0b213b0125be7b79dafc3bdba3b334a/winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1", size = 210090, upload-time = "2025-06-06T06:44:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a1/1d7248d5c62ccbea5f3e0da64ca4529ce99c639c3be2485b6ed709f5c740/winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576", size = 241391, upload-time = "2025-06-06T06:44:09.442Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ae/6a205d8dafc79f7c242be7f940b1e0c1971fd64ab3079bda4b514aa3d714/winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195", size = 415242, upload-time = "2025-06-06T06:44:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/1a555d8bdcb8b920f8e896232c82901cc0cda6d3e4f92842199ae7dff70a/winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1", size = 210022, upload-time = "2025-06-06T06:44:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/aa/24/2b6e536ca7745d788dfd17a2ec376fa03a8c7116dc638bb39b035635484f/winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d", size = 241349, upload-time = "2025-06-06T06:44:12.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7f/6d72973279e2929b2a71ed94198ad4a5d63ee2936e91a11860bf7b431410/winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159", size = 415126, upload-time = "2025-06-06T06:44:13.702Z" }, + { url = "https://files.pythonhosted.org/packages/c8/87/88bd98419a9da77a68e030593fee41702925a7ad8a8aec366945258cbb31/winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad", size = 210257, upload-time = "2025-09-20T07:06:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/e5c2a10d287edd9d3ee8dc24bf7d7f335636b92bf47119768b7dd2fd1669/winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814", size = 241873, upload-time = "2025-09-20T07:06:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/eb9e78397132175f70dd51dfa4f93e489c17d6b313ae9dce60369b8d84a7/winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650", size = 416222, upload-time = "2025-09-20T07:06:43.376Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/cf/671bf29337323cc08f9969cb32312f217d2927d29dbf2964f0dbb378cb90/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b", size = 105535, upload-time = "2025-06-06T07:00:08.146Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d5/5761a8b6dcc56957018970dd443059c8ee8a79de7b07f0b4d143f8e7dc15/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d", size = 114612, upload-time = "2025-06-06T07:00:08.984Z" }, + { url = "https://files.pythonhosted.org/packages/24/0b/7819bb102286752d3572a75d03e6a8000ffe3c6cb7aee3eb136dca383fe2/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80", size = 105017, upload-time = "2025-06-06T07:00:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/54/ff/c4a3de909a875b46fad5e9f4fd412bba48571405bfa802b878954abf128c/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c", size = 105752, upload-time = "2025-06-06T07:00:10.684Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/bfee1f0c8d188c561c5b946ab21f6a0037e60dea110e80b1d6a1d529639f/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca", size = 113356, upload-time = "2025-06-06T07:00:11.541Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/d9da9c29d36cabadef4e19c3e9ba6d2692f6a28224c81fcff757132ea0da/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148", size = 104724, upload-time = "2025-06-06T07:00:12.406Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/797516c5c0f8d7f5b680862e0ed7c1087c58aec0bcf57a417fa90f7eb983/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4", size = 105757, upload-time = "2025-06-06T07:00:13.269Z" }, + { url = "https://files.pythonhosted.org/packages/05/6d/f60588846a065e69a2ec5e67c5f85eb45cb7edef2ee8974cd52fa8504de6/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3", size = 113363, upload-time = "2025-06-06T07:00:14.135Z" }, + { url = "https://files.pythonhosted.org/packages/2c/13/2d3c4762018b26a9f66879676ea15d7551cdbf339c8e8e0c56ea05ea31ef/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2", size = 104722, upload-time = "2025-06-06T07:00:14.999Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/91cfdf941a1ba791708ab3477fc4e46793c8fe9117fc3e0a8c5ac5d7a09c/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a", size = 109015, upload-time = "2025-09-20T07:09:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/7460655628d0f340a93524f5236bb9f8514eb0e1d334b38cba8a89f6c1a6/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c", size = 115931, upload-time = "2025-09-20T07:09:51.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/70/e1248dea2ab881eb76b61ff1ad6cb9c07ac005faf99349e4af0b29bc3f1b/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102", size = 109561, upload-time = "2025-09-20T07:09:52.733Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/5e/c628719e877a89f00cac7ce53f9666acbc5ed6f074130729d5d6768b63ff/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9", size = 89614, upload-time = "2025-06-06T07:00:20.952Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1a/d172d6f1c2fae53535e7f23835025cf39e3002749a0304f18a38e8ed490d/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09", size = 95783, upload-time = "2025-06-06T07:00:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/67/c1/568dfdaea62ca3b13bb70162cb292e5cd0be5bbb98b738961ddcc2edd374/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401", size = 89253, upload-time = "2025-06-06T07:00:22.603Z" }, + { url = "https://files.pythonhosted.org/packages/c9/15/ad05c28e049208c97011728e2debdb45439175f75efe357b6faa4c9ba099/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4", size = 90033, upload-time = "2025-06-06T07:00:23.421Z" }, + { url = "https://files.pythonhosted.org/packages/26/48/074779081841f6eba4987930c4e7adcec38a5985b7dffd9fecc41f39a89c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89", size = 95824, upload-time = "2025-06-06T07:00:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/aa/25/e01966033a02b2d0718710bb47ef4f6b9b5a619ca2c857e06eb5c8e3ed13/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff", size = 89311, upload-time = "2025-06-06T07:00:25.029Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/8fc8e57605ea08dd0723c035ed0c2d0435dace2bc80a66d33aecfea49a56/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90", size = 90037, upload-time = "2025-06-06T07:00:25.818Z" }, + { url = "https://files.pythonhosted.org/packages/86/83/503cf815d84c5ba8c8bc61480f32e55579ebf76630163405f7df39aa297b/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943", size = 95822, upload-time = "2025-06-06T07:00:26.666Z" }, + { url = "https://files.pythonhosted.org/packages/32/13/052be8b6642e6f509b30c194312b37bfee8b6b60ac3bd5ca2968c3ea5b80/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d", size = 89326, upload-time = "2025-06-06T07:00:27.477Z" }, + { url = "https://files.pythonhosted.org/packages/27/3d/421d04a20037370baf13de929bc1dc5438b306a76fe17275ec5d893aae6c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214", size = 92264, upload-time = "2025-09-20T07:09:53.563Z" }, + { url = "https://files.pythonhosted.org/packages/07/c7/43601ab82fe42bcff430b8466d84d92b31be06cc45c7fd64e9aac40f7851/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2", size = 97517, upload-time = "2025-09-20T07:09:54.411Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/e3303f6a25a2d98e424b06580fc85bbfd068f383424c67fa47cb1b357a46/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a", size = 94122, upload-time = "2025-09-20T07:09:55.187Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/349a5d958be8c0570f0a49bbb746088bcfaa81555accb57503ba01185359/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c", size = 182312, upload-time = "2025-06-06T07:00:49.974Z" }, + { url = "https://files.pythonhosted.org/packages/90/db/929ab0085ec89e46bd3a58c74b451dd770c3285dfa0cbd4f4aa4730da004/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415", size = 187768, upload-time = "2025-06-06T07:00:50.853Z" }, + { url = "https://files.pythonhosted.org/packages/a3/53/f316e2224c384178204430439f04f9b72017fe8237e341a9aebb20da8191/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7", size = 184189, upload-time = "2025-06-06T07:00:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a1/75ac783a5faee9b455fef2f53b7fef97b21ed60d52401b44c690202141e4/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557", size = 183326, upload-time = "2025-06-06T07:00:52.662Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d9/a9dcc15322d2f5c7dfd491bd7ab121e36437caf78ebfa92bc0dd0546e2ca/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d", size = 187810, upload-time = "2025-06-06T07:00:53.594Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fc/47d00af076f558267097af3050910beda6bf8a21ceaa5830bbd26fcaf85e/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984", size = 184516, upload-time = "2025-06-06T07:00:55.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/93/30b45ce473d1a604908221a1fa035fe8d5e4bb9008e820ae671a21dab94c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0", size = 183342, upload-time = "2025-06-06T07:00:56.16Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3b/eb9d99b82a36002d7885206d00ea34f4a23db69c16c94816434ded728fa3/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30", size = 187844, upload-time = "2025-06-06T07:00:57.134Z" }, + { url = "https://files.pythonhosted.org/packages/84/9b/ebbbe9be9a3e640dcfc5f166eb48f2f9d8ce42553f83aa9f4c5dcd9eb5f5/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383", size = 184540, upload-time = "2025-06-06T07:00:58.081Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/cb447ca7730a1e05730272309b074da6a04af29a8c0f5121014db8a2fc02/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f", size = 185249, upload-time = "2025-09-20T07:09:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fa/f465d5d44dda166bf7ec64b7a950f57eca61f165bfe18345e9a5ea542def/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851", size = 193739, upload-time = "2025-09-20T07:09:59.893Z" }, + { url = "https://files.pythonhosted.org/packages/78/08/51c53ac3c704cd92da5ed7e7b9b57159052f6e46744e4f7e447ed708aa22/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892", size = 194836, upload-time = "2025-09-20T07:10:00.87Z" }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/92/ca1fd311d96fce15fba25543a2ae3cb829744a8af548a11d74233d0e4f64/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011", size = 129898, upload-time = "2025-06-06T07:01:59.687Z" }, + { url = "https://files.pythonhosted.org/packages/03/fd/5bd5da5d7997725ba3f1995c16aa1c3362937f8ff68ad4cadfd3415eebcb/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd", size = 142361, upload-time = "2025-06-06T07:02:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/df/be/d423b63e740600e0617ddb85fba3ef99e7bbff02299fe46323bfe624a382/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817", size = 135808, upload-time = "2025-06-06T07:02:01.4Z" }, + { url = "https://files.pythonhosted.org/packages/31/3e/81642208ecd6c6c936f35a39a433c54e3f68e09d316546b8f953581ae334/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3", size = 130249, upload-time = "2025-06-06T07:02:02.237Z" }, + { url = "https://files.pythonhosted.org/packages/00/f4/a9ede5f3f0d86abfc7590726cf711133d97419b49ced372fca532e4f0696/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc", size = 141512, upload-time = "2025-06-06T07:02:03.424Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/4fad07c03124bdc3acd64f80f3bd3cc4417ea641e07bb16a9503afd3e554/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca", size = 135383, upload-time = "2025-06-06T07:02:04.312Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7d/ebd712ab8ccd599c593796fbcd606abe22b5a8e20db134aa87987d67ac0e/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9", size = 130276, upload-time = "2025-06-06T07:02:05.178Z" }, + { url = "https://files.pythonhosted.org/packages/70/de/f30daaaa0e6f4edb6bd7ddb3e058bd453c9ad90c032a4545c4d4639338aa/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015", size = 141536, upload-time = "2025-06-06T07:02:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/9a6aafdc74a085c550641a325be463bf4b811f6f605766c9cd4f4b5c19d2/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4", size = 135362, upload-time = "2025-06-06T07:02:06.997Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/5785cd1ec54dc0f0e6f3e6a466d07a62b8014a6e2b782e80444ef87e83ab/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106", size = 134252, upload-time = "2025-09-20T07:10:12.935Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/68d91068048410f49794c0b19c45759c63ca559607068cfe5affba2f211b/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472", size = 145509, upload-time = "2025-09-20T07:10:13.797Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a4/898951d5bfc474aa9c7d133fe30870f0f2184f4ba3027eafb779d30eb7bc/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22", size = 141334, upload-time = "2025-09-20T07:10:14.637Z" }, +] + +[[package]] +name = "winrt-windows-devices-radios" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/a0/4a8b51da15de218cec04bcc1cd85b4b93bcfd8ebe50a5f0a7eee28836dc6/winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e", size = 38505, upload-time = "2025-06-06T07:07:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/de/49/ba69e3180585dbc6f3336a09fef7cba4558a6a1e7d500500f62c1478418e/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104", size = 40157, upload-time = "2025-06-06T07:07:59.948Z" }, + { url = "https://files.pythonhosted.org/packages/9c/92/64817f71a20ecf842da36dc3848f42614217688137a69c93fda8a6103155/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a", size = 36976, upload-time = "2025-06-06T07:08:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e0/4731a3c412318b2c5e74a8803a32e2fb9afc2c98368c6b61a422eb359e7e/winrt_windows_devices_radios-3.2.1-cp312-cp312-win32.whl", hash = "sha256:c3e683ce682338a5a5ed465f735e223ba7a22f16d0bbea2d070962bc7657edbb", size = 38606, upload-time = "2025-06-06T07:08:01.477Z" }, + { url = "https://files.pythonhosted.org/packages/37/8e/91464854dfc9e0be9ce8dcbe2bd6a67c19b68ab91584fc5de0f4f13e78f8/winrt_windows_devices_radios-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a116e552a3f38607b9be558fb2e7de9b4450d1f9080069944d74d80cdda1873e", size = 40172, upload-time = "2025-06-06T07:08:02.214Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0d/1bd62f606b6c4dfa936fccc4712be5506a40fc5d1b7177c3d3cbcaf30972/winrt_windows_devices_radios-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4c28822f9251c9d547324f596b5c2581f050254ded05e5b786c650a3502744c1", size = 36989, upload-time = "2025-06-06T07:08:03.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/94/c22a14fd424632f3f3c0b25672218db9e8f4ae9e1355e0b148f2fe6015b5/winrt_windows_devices_radios-3.2.1-cp313-cp313-win32.whl", hash = "sha256:ae4a0065927fcd2d10215223f8a46be6fb89bad71cb4edd25dae3d01c137b3a8", size = 38613, upload-time = "2025-06-06T07:08:04.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/c1/24cec0cc228642554b48d436a7617d7162fb952919c55fc26e2d99c310bd/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:bf1a975f46a2aa271ffea1340be0c7e64985050d07433e701343dddc22a72290", size = 40180, upload-time = "2025-06-06T07:08:04.849Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/776453af26e78c0d0c0e1bfa89f86fd81322872f31a3e5dafb344dd47bf2/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:10b298ed154c5824cea2de174afce1694ed2aabfb58826de814074027ffef96f", size = 36989, upload-time = "2025-06-06T07:08:05.576Z" }, + { url = "https://files.pythonhosted.org/packages/76/79/4627afae6b389ddd1e5f1d691663c6b14d6c8f98959082aed1217cc57ef9/winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6", size = 39521, upload-time = "2025-09-20T07:11:17.992Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/c6aea91908ee7279ed51d12157bc8aeecb8850af2441073c3c91b261ad31/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf", size = 41121, upload-time = "2025-09-20T07:11:18.747Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/652f14e3c501452ad8e0723518d9bbd729219b47f4a4dbe2966c2f82dca8/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312", size = 38114, upload-time = "2025-09-20T07:11:19.599Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/36/09b9757f7cbf269e67008ea2ad188a44f974c94c9b49ebf0b52d1a8c4069/winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437", size = 111944, upload-time = "2025-06-06T07:10:58.496Z" }, + { url = "https://files.pythonhosted.org/packages/05/a5/216d66df6bdcee58eb3877fabc1544337e23f850bf9f93838db7f5698371/winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e", size = 118465, upload-time = "2025-06-06T07:10:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/be/ca/48ca8b5bc5be5c7a5516c9e1d9a21861b4217e1b4ee57923aab6f13fa411/winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671", size = 109609, upload-time = "2025-06-06T07:11:00.54Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/495e304ddedd5ff2f196efbde906265cb75ade4d79e2937837f72ef654a0/winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b", size = 112169, upload-time = "2025-06-06T07:11:01.438Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5e/b5059e4ece095351c496c9499783130c302d25e353c18031d5231b1b3b3c/winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9", size = 118668, upload-time = "2025-06-06T07:11:02.475Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/acbcb3ef07b1b67e2de4afab9176a5282cfd775afd073efe6828dfc65ace/winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37", size = 109671, upload-time = "2025-06-06T07:11:03.538Z" }, + { url = "https://files.pythonhosted.org/packages/7b/71/5e87131e4aecc8546c76b9e190bfe4e1292d028bda3f9dd03b005d19c76c/winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46", size = 112184, upload-time = "2025-06-06T07:11:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/8d5108461351d4f6017f550af8874e90c14007f9122fa2eab9f9e0e9b4e1/winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479", size = 118672, upload-time = "2025-06-06T07:11:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/44/f5/2edf70922a3d03500dab17121b90d368979bd30016f6dbca0d043f0c71f1/winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4", size = 109673, upload-time = "2025-06-06T07:11:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/d77346e39fe0c81f718cde49f83fe77c368c0e14c6418f72dfa1e7ef22d0/winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da", size = 114590, upload-time = "2025-09-20T07:11:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/4d2b545bea0f34f68df6d4d4ca22950ff8a935497811dccdc0ca58737a05/winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5", size = 122148, upload-time = "2025-09-20T07:11:50.826Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/b9d3a11cac73444c0a3703200161cd7267dab5ab85fd00e1f965526e74a8/winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87", size = 114360, upload-time = "2025-09-20T07:11:51.626Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/b3/7e4a75c62e86bedf9458b7ec8dfed74cff3236e0b4b2288f95967d5cc4d2/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7", size = 59693, upload-time = "2025-06-06T07:11:13.388Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/049db1d95fdfc0c8451dc6db17442ed4e6b2aba361c425c0bb8dc8c98c4a/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89", size = 70828, upload-time = "2025-06-06T07:11:14.54Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6b/a04974f5555c86452e54c19d063d9fd45f0fe9f2a6858e7fe12c639043fb/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523", size = 59051, upload-time = "2025-06-06T07:11:15.379Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0b/7802349391466d3f7e8f62f588f36a1a0b6560abfcdbdaa426fe21d322b4/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6", size = 60060, upload-time = "2025-06-06T07:11:16.173Z" }, + { url = "https://files.pythonhosted.org/packages/37/94/5b888713e472746635a382e523513ab1b8200af55c5b56bc70e1e4369115/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b", size = 69058, upload-time = "2025-06-06T07:11:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/829273622c9b37c67b97f187b92be318404f7d33db045e31d72b7d50f54c/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84", size = 58793, upload-time = "2025-06-06T07:11:17.837Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cd/99ef050d80bea2922fa1ded93e5c250732634095d8bd3595dd808083e5ca/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9", size = 60063, upload-time = "2025-06-06T07:11:18.65Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/4f75fd6a4c96f1e9bee198c5dc9a9b57e87a9c38117e1b5e423401886353/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10", size = 69057, upload-time = "2025-06-06T07:11:19.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/de47ccc390017ec5575e7e7fd9f659ee3747c52049cdb2969b1b538ce947/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2", size = 58792, upload-time = "2025-06-06T07:11:20.24Z" }, + { url = "https://files.pythonhosted.org/packages/e1/47/b3301d964422d4611c181348149a7c5956a2a76e6339de451a000d4ae8e7/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab", size = 62211, upload-time = "2025-09-20T07:11:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/5f2c940ff606297129e93ebd6030c813e6a43a786de7fc33ccb268e0b06b/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4", size = 70399, upload-time = "2025-09-20T07:11:53.254Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/2c8eb89062c71d4be73d618457ed68e7e2ba29a660ac26349d44fc121cbf/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01", size = 61392, upload-time = "2025-09-20T07:11:53.993Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/60/a9e0dc03434aa29e6b5c83067e988cd5934adf830cd9f87cbbc06569ca32/winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69", size = 127509, upload-time = "2025-06-06T14:01:58.971Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/6c9c21b5e75ff5927a130da9eaf5ab628dfa1f93b64c181f0193706cbd6c/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5", size = 132491, upload-time = "2025-06-06T14:01:59.788Z" }, + { url = "https://files.pythonhosted.org/packages/38/ca/d0a02045d445cbf1029d65f01b487fdded5b333c0367a8bae0565b3def00/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780", size = 128577, upload-time = "2025-06-06T14:02:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/e7/7d3f2a4a442f264e05cab2bdf20ed1b95cb3f753bd1b0f277f2b49fb8335/winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78", size = 127787, upload-time = "2025-06-06T14:02:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2f/cc36f475f8af293f40e2c2a5d6c2e75a189c2c2d4d01ecb3551578518c79/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc", size = 131849, upload-time = "2025-06-06T14:02:03.09Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/896fb734f7456910ec412f3f3adfdc3f0dc3134864a496d5b120592f3bfd/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307", size = 128144, upload-time = "2025-06-06T14:02:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d2/24d9f59bdc05e741261d5bec3bcea9a848d57714126a263df840e2b515a8/winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163", size = 127774, upload-time = "2025-06-06T14:02:04.752Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/601724453b885265c7779d5f8025b043a68447cbc64ceb9149d674d5b724/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915", size = 131827, upload-time = "2025-06-06T14:02:05.601Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/a419675a6087c9ea496968c9b7805ef234afa585b7483e2269608a12b044/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d", size = 128180, upload-time = "2025-06-06T14:02:06.759Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/2869ea2112c565caace73c9301afd1d7afcc49bdd37fac058f0178ba95d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee", size = 131701, upload-time = "2025-09-20T07:17:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/aae50b1d0e37b5a61055759aedd42c6c99d7c17ab8c3e568ab33c0288938/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0", size = 135566, upload-time = "2025-09-20T07:17:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c3/6d3ce7a58e6c828e0795c9db8790d0593dd7fdf296e513c999150deb98d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987", size = 134393, upload-time = "2025-09-20T07:17:18.802Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index d8cb93fd74..1a9dafc8f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -607,6 +607,8 @@ exclude_also = [ max_size_kb = 50 ignore = [ "uv.lock", + "packages/dimos-gpd-worker-demo/pixi.lock", + "packages/dimos-gpd-worker-demo/uv.lock", "*/package-lock.json", "dimos/dashboard/dimos.rbl", "dimos/web/dimos_interface/themes.json", From 984d3ee67d1492e54aa4c88971394f5d7e192ef5 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 29 Jun 2026 10:40:32 -0700 Subject: [PATCH 098/110] spec: archive python project runtime environments --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../runtime-environment-preparation/spec.md | 0 .../runtime-environment-registry/spec.md | 0 .../specs/venv-module-packaging/spec.md | 0 .../specs/venv-module-placement/spec.md | 0 .../tasks.md | 0 .../runtime-environment-preparation/spec.md | 46 +++++++++++++++++++ .../runtime-environment-registry/spec.md | 30 ++++++++++++ openspec/specs/venv-module-packaging/spec.md | 26 +++++++++++ openspec/specs/venv-module-placement/spec.md | 26 +++++++++++ 12 files changed, 128 insertions(+) rename openspec/changes/{python-project-runtime-environments => archive/2026-06-29-python-project-runtime-environments}/.openspec.yaml (100%) rename openspec/changes/{python-project-runtime-environments => archive/2026-06-29-python-project-runtime-environments}/design.md (100%) rename openspec/changes/{python-project-runtime-environments => archive/2026-06-29-python-project-runtime-environments}/proposal.md (100%) rename openspec/changes/{python-project-runtime-environments => archive/2026-06-29-python-project-runtime-environments}/specs/runtime-environment-preparation/spec.md (100%) rename openspec/changes/{python-project-runtime-environments => archive/2026-06-29-python-project-runtime-environments}/specs/runtime-environment-registry/spec.md (100%) rename openspec/changes/{python-project-runtime-environments => archive/2026-06-29-python-project-runtime-environments}/specs/venv-module-packaging/spec.md (100%) rename openspec/changes/{python-project-runtime-environments => archive/2026-06-29-python-project-runtime-environments}/specs/venv-module-placement/spec.md (100%) rename openspec/changes/{python-project-runtime-environments => archive/2026-06-29-python-project-runtime-environments}/tasks.md (100%) create mode 100644 openspec/specs/runtime-environment-preparation/spec.md diff --git a/openspec/changes/python-project-runtime-environments/.openspec.yaml b/openspec/changes/archive/2026-06-29-python-project-runtime-environments/.openspec.yaml similarity index 100% rename from openspec/changes/python-project-runtime-environments/.openspec.yaml rename to openspec/changes/archive/2026-06-29-python-project-runtime-environments/.openspec.yaml diff --git a/openspec/changes/python-project-runtime-environments/design.md b/openspec/changes/archive/2026-06-29-python-project-runtime-environments/design.md similarity index 100% rename from openspec/changes/python-project-runtime-environments/design.md rename to openspec/changes/archive/2026-06-29-python-project-runtime-environments/design.md diff --git a/openspec/changes/python-project-runtime-environments/proposal.md b/openspec/changes/archive/2026-06-29-python-project-runtime-environments/proposal.md similarity index 100% rename from openspec/changes/python-project-runtime-environments/proposal.md rename to openspec/changes/archive/2026-06-29-python-project-runtime-environments/proposal.md diff --git a/openspec/changes/python-project-runtime-environments/specs/runtime-environment-preparation/spec.md b/openspec/changes/archive/2026-06-29-python-project-runtime-environments/specs/runtime-environment-preparation/spec.md similarity index 100% rename from openspec/changes/python-project-runtime-environments/specs/runtime-environment-preparation/spec.md rename to openspec/changes/archive/2026-06-29-python-project-runtime-environments/specs/runtime-environment-preparation/spec.md diff --git a/openspec/changes/python-project-runtime-environments/specs/runtime-environment-registry/spec.md b/openspec/changes/archive/2026-06-29-python-project-runtime-environments/specs/runtime-environment-registry/spec.md similarity index 100% rename from openspec/changes/python-project-runtime-environments/specs/runtime-environment-registry/spec.md rename to openspec/changes/archive/2026-06-29-python-project-runtime-environments/specs/runtime-environment-registry/spec.md diff --git a/openspec/changes/python-project-runtime-environments/specs/venv-module-packaging/spec.md b/openspec/changes/archive/2026-06-29-python-project-runtime-environments/specs/venv-module-packaging/spec.md similarity index 100% rename from openspec/changes/python-project-runtime-environments/specs/venv-module-packaging/spec.md rename to openspec/changes/archive/2026-06-29-python-project-runtime-environments/specs/venv-module-packaging/spec.md diff --git a/openspec/changes/python-project-runtime-environments/specs/venv-module-placement/spec.md b/openspec/changes/archive/2026-06-29-python-project-runtime-environments/specs/venv-module-placement/spec.md similarity index 100% rename from openspec/changes/python-project-runtime-environments/specs/venv-module-placement/spec.md rename to openspec/changes/archive/2026-06-29-python-project-runtime-environments/specs/venv-module-placement/spec.md diff --git a/openspec/changes/python-project-runtime-environments/tasks.md b/openspec/changes/archive/2026-06-29-python-project-runtime-environments/tasks.md similarity index 100% rename from openspec/changes/python-project-runtime-environments/tasks.md rename to openspec/changes/archive/2026-06-29-python-project-runtime-environments/tasks.md diff --git a/openspec/specs/runtime-environment-preparation/spec.md b/openspec/specs/runtime-environment-preparation/spec.md new file mode 100644 index 0000000000..f4cb117845 --- /dev/null +++ b/openspec/specs/runtime-environment-preparation/spec.md @@ -0,0 +1,46 @@ +## Purpose + +Define explicit, blueprint-scoped preparation for runtime environments used by DimOS module placements. + +## Requirements + +### Requirement: Runtime preparation is blueprint-scoped +The system SHALL provide an explicit runtime preparation command that resolves runtime environment names by loading a blueprint configuration before preparing any environment. + +#### Scenario: Prepare active placement runtimes for blueprint +- **WHEN** the user runs `dimos runtime prepare ` with valid run-like configuration flags +- **THEN** the system loads the blueprint, identifies active module placements, and prepares only the runtime environments referenced by those active placements + +#### Scenario: Runtime name is resolved within blueprint +- **WHEN** the user runs `dimos runtime prepare --runtime ` +- **THEN** the system resolves `` against the runtime environment registry produced by that blueprint configuration + +#### Scenario: Runtime exists but is unused +- **WHEN** the requested runtime name exists in the blueprint registry but is not referenced by an active module placement +- **THEN** the preparation command fails with an error explaining that the runtime is not used by the active blueprint configuration + +### Requirement: Runtime preparation is explicit and repeatable +The system SHALL keep runtime environment preparation separate from blueprint execution and SHALL make the preparation command safe to rerun when project manifests change. + +#### Scenario: Prepare uv-only project runtime +- **WHEN** an active runtime placement references a Python project runtime with `pyproject.toml` and no `pixi.toml` +- **THEN** runtime preparation runs `uv venv --seed` and `uv sync` from the project directory + +#### Scenario: Prepare Pixi-backed uv project runtime +- **WHEN** an active runtime placement references a Python project runtime with both `pyproject.toml` and `pixi.toml` +- **THEN** runtime preparation runs `pixi install`, `pixi run uv venv -p .pixi/envs/default/bin/python --seed`, and `pixi run uv sync` from the project directory + +#### Scenario: Prepare reruns sync commands +- **WHEN** a prepared project runtime already has a `.venv` +- **THEN** runtime preparation still runs the applicable Pixi and uv sync commands instead of skipping solely because the `.venv` exists + +### Requirement: Blueprint run fails fast for unprepared project runtimes +The system SHALL NOT install, sync, or otherwise mutate Python project runtime environments during normal blueprint run or build. + +#### Scenario: Project runtime is missing venv Python +- **WHEN** `dimos run ` evaluates an active placement using a Python project runtime whose `.venv/bin/python` is missing +- **THEN** blueprint build fails before worker launch with a message naming the runtime, blueprint, project path, missing executable, and the `dimos runtime prepare` command to run + +#### Scenario: Project runtime exists +- **WHEN** `dimos run ` evaluates an active placement using a Python project runtime whose `.venv/bin/python` exists +- **THEN** the system attempts worker launch without running Pixi or uv sync commands diff --git a/openspec/specs/runtime-environment-registry/spec.md b/openspec/specs/runtime-environment-registry/spec.md index 61b1ec5b76..cb563fe819 100644 --- a/openspec/specs/runtime-environment-registry/spec.md +++ b/openspec/specs/runtime-environment-registry/spec.md @@ -47,3 +47,33 @@ The system SHALL keep existing `NativeModuleConfig` executable, build command, c #### Scenario: Runtime environment and legacy overrides are combined deterministically - **WHEN** a NativeModule references a runtime environment and also supplies supported legacy override fields - **THEN** DimOS applies a documented precedence order and launches with the resulting executable, cwd, env, and build behavior + +### Requirement: Python project runtime environments resolve by convention +The system SHALL support a named Python project runtime environment that resolves Python worker launch material from a project directory using DimOS-defined conventions. + +#### Scenario: Register Python project runtime environment +- **WHEN** a blueprint registers `PythonProjectRuntimeEnvironment(name="worker", project=Path("packages/worker"))` +- **THEN** the runtime environment registry stores the environment under `worker` without requiring explicit manifest, venv, or Pixi path fields + +#### Scenario: pyproject is required +- **WHEN** a Python project runtime environment is resolved for preparation or worker launch and the project directory does not contain `pyproject.toml` +- **THEN** resolution fails with an actionable error explaining that first-slice Python project runtimes require a uv project + +#### Scenario: Pixi is optional +- **WHEN** a Python project runtime environment project contains `pyproject.toml` but no `pixi.toml` +- **THEN** the system treats it as a uv-only Python project runtime + +#### Scenario: Pixi-backed uv project is detected +- **WHEN** a Python project runtime environment project contains both `pyproject.toml` and `pixi.toml` +- **THEN** the system treats it as a Pixi-backed uv runtime where Pixi supplies the Python used to create the project-local uv `.venv` + +### Requirement: Python project runtime launch is non-mutating +The system SHALL resolve Python project runtime launch commands without performing environment preparation during worker deployment. + +#### Scenario: Resolve uv-only launch command +- **WHEN** a uv-only Python project runtime is used for worker launch +- **THEN** the worker launcher receives a non-mutating command equivalent to `uv run --no-sync python -m dimos.core.coordination.venv_worker_entrypoint ...` + +#### Scenario: Resolve Pixi-backed launch command +- **WHEN** a Pixi-backed uv runtime is used for worker launch +- **THEN** the worker launcher receives a non-mutating command equivalent to `pixi run uv run --no-sync python -m dimos.core.coordination.venv_worker_entrypoint ...` diff --git a/openspec/specs/venv-module-packaging/spec.md b/openspec/specs/venv-module-packaging/spec.md index dbc3b82884..25bb7936a6 100644 --- a/openspec/specs/venv-module-packaging/spec.md +++ b/openspec/specs/venv-module-packaging/spec.md @@ -47,3 +47,29 @@ The system SHALL include a demo package and blueprint proving that a Module can #### Scenario: Venv worker uses demo runtime helper - **WHEN** the demo blueprint runs with the demo Module placed into its named Python runtime environment - **THEN** the Module uses its package-local runtime helper inside the venv and publishes or responds through normal DimOS Module behavior + +### Requirement: Venv Module packages can use Pixi-backed native dependencies +The system SHALL support venv Module packages whose Python dependencies require native or C++ build dependencies supplied by an optional package-local `pixi.toml`. + +#### Scenario: Package declares Python dependency closure with pyproject +- **WHEN** a venv Module package is used as a Python project runtime +- **THEN** its `pyproject.toml` declares the Python package dependencies that uv installs into the project-local `.venv` + +#### Scenario: Package declares native build environment with pixi +- **WHEN** a venv Module package includes `pixi.toml` +- **THEN** Pixi supplies the native build tools and libraries used while uv installs the package dependency closure + +### Requirement: GPD demo proves native Python binding import in worker runtime +The system SHALL include a GPD worker demo package that proves a Python binding with native build requirements can be prepared and imported in a placed venv worker. + +#### Scenario: Demo package depends on pinned GPD source +- **WHEN** the GPD demo package is prepared +- **THEN** uv installs a dependency on `TomCC7/gpd` pinned to commit `c088d8ae2f7965b067e9a12b3c0dacdbe9da924a` + +#### Scenario: Demo Module imports GPD in worker +- **WHEN** the GPD demo blueprint runs with its dummy Module placed into the project runtime +- **THEN** a Module RPC lazily imports `gpd.core` inside the worker process and reports import success through normal DimOS RPC behavior + +#### Scenario: Pixi integration test is optional when Pixi is unavailable +- **WHEN** the automated test environment does not have Pixi installed +- **THEN** Pixi/GPD integration tests are skipped while uv-only and command-construction tests still run diff --git a/openspec/specs/venv-module-placement/spec.md b/openspec/specs/venv-module-placement/spec.md index ff153a4408..af298830e3 100644 --- a/openspec/specs/venv-module-placement/spec.md +++ b/openspec/specs/venv-module-placement/spec.md @@ -62,3 +62,29 @@ The system SHALL fail the normal blueprint build/deploy lifecycle when a named v #### Scenario: Worker runtime import failure fails build - **WHEN** a venv worker starts but cannot import compatible DimOS worker runtime modules - **THEN** deployment fails instead of silently degrading into a partial blueprint + +### Requirement: Project runtime workers preserve worker lifecycle semantics +The system SHALL deploy Modules placed into Python project runtimes through the existing DimOS worker lifecycle and control-channel semantics. + +#### Scenario: Project runtime worker connects to coordinator +- **WHEN** the coordinator launches a worker through a uv-only or Pixi-backed project runtime command +- **THEN** the worker connects to the coordinator's local multiprocessing connection endpoint before receiving deploy requests + +#### Scenario: Project runtime worker shuts down normally +- **WHEN** a project-runtime worker receives the normal DimOS shutdown request +- **THEN** the worker exits through the existing worker shutdown path without requiring a separate shutdown protocol for uv or Pixi wrappers + +#### Scenario: Project runtime worker termination fallback +- **WHEN** a project-runtime worker does not exit after the normal shutdown timeout +- **THEN** DimOS applies the existing worker process-handle termination fallback to the launched process and tests verify whether additional cleanup is necessary + +### Requirement: Project runtime placement remains active-module scoped +The system SHALL keep Python project runtime placement scoped to active module placements in the loaded blueprint configuration. + +#### Scenario: Unused project runtime is not launched +- **WHEN** a blueprint registers a Python project runtime environment that is not referenced by any active module placement +- **THEN** blueprint build does not prepare, launch, or allocate a worker pool for that runtime + +#### Scenario: Same Module can run without project runtime placement +- **WHEN** a Module class appears in a blueprint without project runtime placement after previously being used with a project runtime +- **THEN** the coordinator deploys it through the default Python worker pool From 16d293f114ad2092df17987488c71c6ecba0229d Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 29 Jun 2026 14:44:03 -0700 Subject: [PATCH 099/110] feat: add roboplan trajectory postprocessing --- CONTEXT.md | 22 +- dimos/manipulation/manipulation_module.py | 1 + dimos/manipulation/planning/spec/config.py | 6 + dimos/manipulation/planning/spec/models.py | 26 + .../manipulation/planning/spec/test_models.py | 42 +- .../planning/world/roboplan_world.py | 529 +++++++++++++++++- dimos/manipulation/test_manipulation_unit.py | 24 + dimos/manipulation/test_roboplan_world.py | 276 +++++++++ docs/capabilities/manipulation/readme.md | 10 + .../.openspec.yaml | 2 + .../design.md | 106 ++++ .../proposal.md | 32 ++ .../manipulation-linear-tcp-planning/spec.md | 27 + .../spec.md | 69 +++ .../specs/roboplan-cartesian-planning/spec.md | 27 + .../tasks.md | 36 ++ .../manipulation-linear-tcp-planning/spec.md | 26 + .../spec.md | 68 +++ .../specs/roboplan-cartesian-planning/spec.md | 26 + 19 files changed, 1330 insertions(+), 25 deletions(-) create mode 100644 openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/design.md create mode 100644 openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/proposal.md create mode 100644 openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/manipulation-linear-tcp-planning/spec.md create mode 100644 openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/manipulation-trajectory-parametrization/spec.md create mode 100644 openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/roboplan-cartesian-planning/spec.md create mode 100644 openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md index fc726578ea..604e4620e3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -17,9 +17,29 @@ A planning group included in a planning request that may move as part of the pla _Avoid_: extra group, passive target group, unconstrained target **Linear TCP path**: -A motion recipe where the tool center point follows a straight Cartesian segment from its start pose to its target pose. +A motion recipe where the tool center point follows a straight Cartesian segment from its start pose to its target pose within configured Cartesian tolerance. _Avoid_: linear joint motion, linear motion +**Linear TCP trajectory smoothing**: +A manipulation-planning capability that makes a Linear TCP path executable without treating every intermediate Cartesian sample as a stop, while preserving Cartesian-line tolerance. +_Avoid_: waypoint skipping, making linear motion faster + +**Adaptive-conservative smoothing**: +A trajectory smoothing policy that starts with an aggressive simplification and, on validation failure, preserves more of the original path rather than relaxing correctness tolerances. +_Avoid_: tolerance loosening, unsafe smoothing retry + +**Trajectory post-processing pipeline**: +A staged manipulation-planning capability that may refine a geometric path, validate the refinement, assign timing, and apply execution-oriented smoothing while preserving the path's declared constraints. +_Avoid_: hidden waypoint hack, one-off retiming step + +**Path-constraint metadata**: +Optional planning metadata attached to a geometric path that declares the path constraints any post-processing must preserve. +_Avoid_: planner debug data, visualization-only metadata + +**Non-blocking smoothing fallback**: +A trajectory post-processing policy where smoothing failures fall back to the original geometric path rather than failing parametrization; the worst expected outcome is a slower valid trajectory. +_Avoid_: strict smoothing gate, smoothing-required parametrization + **Composite RoboPlan model**: A RoboPlan-facing robot model that represents multiple registered robot models as one planning scene. _Avoid_: combined URDF, merged robot scene diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 2e17cd41d9..e39c2dec2d 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -640,6 +640,7 @@ def _store_generated_plan( path_length=result.path_length, iterations=result.iterations, message=result.message, + path_constraints=result.path_constraints, ) self._last_trajectory = None diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index d18ef4e00a..0391d03108 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -55,6 +55,12 @@ class TrajectoryParametrizationConfig(ModuleConfig): roboplan_max_adaptive_iterations: int = Field(default=10, ge=1) roboplan_max_adaptive_step_size: float = Field(default=0.05, gt=0.0) roboplan_max_blend_deviation: float = Field(default=0.01, gt=0.0) + roboplan_smoothing_enabled: bool = True + roboplan_smoothing_min_waypoints: int = Field(default=8, ge=3) + roboplan_smoothing_max_attempts: int = Field(default=4, ge=1) + roboplan_smoothing_max_joint_deviation: float = Field(default=0.02, ge=0.0) + roboplan_smoothing_edge_step_size: float = Field(default=0.02, gt=0.0) + roboplan_smoothing_min_keep_fraction: float = Field(default=0.1, gt=0.0, le=1.0) class RobotModelConfig(ModuleConfig): diff --git a/dimos/manipulation/planning/spec/models.py b/dimos/manipulation/planning/spec/models.py index 4a9a1bf2a8..bd716a5b1c 100644 --- a/dimos/manipulation/planning/spec/models.py +++ b/dimos/manipulation/planning/spec/models.py @@ -93,6 +93,9 @@ class PlanningSceneInfo: CartesianPathMode: TypeAlias = Literal["free", "linear"] """Mode describing requested Cartesian path semantics.""" +PathConstraintKind: TypeAlias = Literal["linear_tcp"] +"""Kind discriminator for geometric path constraints.""" + @dataclass(frozen=True) class CartesianDelta: @@ -107,6 +110,27 @@ class CartesianDelta: frame_id: str = "world" +@dataclass(frozen=True) +class LinearTcpPathConstraint: + """Straight-line TCP constraint carried by a geometric plan. + + The constrained TCP must follow the world-frame segment from `start_pose` to + `target_pose` within the provided translational and rotational tolerances. + """ + + kind: PathConstraintKind = "linear_tcp" + group_id: PlanningGroupID = "" + tcp_frame: str = "" + start_pose: PoseStamped | None = None + target_pose: PoseStamped | None = None + max_translational_deviation: float = 1e-3 + max_rotational_deviation: float = 1e-2 + + +PathConstraintMetadata: TypeAlias = LinearTcpPathConstraint +"""Optional metadata declaring constraints a post-processor must preserve.""" + + @dataclass(frozen=True) class CollisionCheckResult: """Result of a planning-world collision target check.""" @@ -140,6 +164,7 @@ class GeneratedPlan: path_length: float = 0.0 iterations: int = 0 message: str = "" + path_constraints: PathConstraintMetadata | None = None def is_success(self) -> bool: """Check if planning was successful.""" @@ -263,6 +288,7 @@ class PlanningResult: message: str = "" # Optional timing (set by optimization-based planners) timestamps: list[float] | None = None + path_constraints: PathConstraintMetadata | None = None def is_success(self) -> bool: """Check if planning was successful.""" diff --git a/dimos/manipulation/planning/spec/test_models.py b/dimos/manipulation/planning/spec/test_models.py index eaa8448d43..b479cc4b24 100644 --- a/dimos/manipulation/planning/spec/test_models.py +++ b/dimos/manipulation/planning/spec/test_models.py @@ -20,7 +20,15 @@ import pytest -from dimos.manipulation.planning.spec.models import CartesianDelta +from dimos.manipulation.planning.spec.enums import PlanningStatus +from dimos.manipulation.planning.spec.models import ( + CartesianDelta, + GeneratedPlan, + LinearTcpPathConstraint, + PlanningResult, +) +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Vector3 import Vector3 def test_cartesian_delta_defaults_to_world_identity_delta() -> None: @@ -44,3 +52,35 @@ def test_cartesian_delta_is_frozen() -> None: with pytest.raises(FrozenInstanceError): delta.frame_id = "tool" # type: ignore[misc] + + +def test_planning_result_and_generated_plan_default_to_no_path_constraints() -> None: + result = PlanningResult(status=PlanningStatus.SUCCESS) + plan = GeneratedPlan(group_ids=("arm/manipulator",), status=PlanningStatus.SUCCESS) + + assert result.path_constraints is None + assert plan.path_constraints is None + + +def test_linear_tcp_path_constraint_fields_and_frozen_behavior() -> None: + start = PoseStamped(frame_id="world", position=Vector3(0.0, 0.0, 0.0)) # type: ignore[call-arg] + target = PoseStamped(frame_id="world", position=Vector3(0.1, 0.0, 0.0)) # type: ignore[call-arg] + + constraint = LinearTcpPathConstraint( + group_id="arm/manipulator", + tcp_frame="tcp", + start_pose=start, + target_pose=target, + max_translational_deviation=0.002, + max_rotational_deviation=0.003, + ) + + assert constraint.kind == "linear_tcp" + assert constraint.group_id == "arm/manipulator" + assert constraint.tcp_frame == "tcp" + assert constraint.start_pose is start + assert constraint.target_pose is target + assert constraint.max_translational_deviation == pytest.approx(0.002) + assert constraint.max_rotational_deviation == pytest.approx(0.003) + with pytest.raises(FrozenInstanceError): + constraint.tcp_frame = "other_tcp" # type: ignore[misc] diff --git a/dimos/manipulation/planning/world/roboplan_world.py b/dimos/manipulation/planning/world/roboplan_world.py index 536de7b1b8..540331bd00 100644 --- a/dimos/manipulation/planning/world/roboplan_world.py +++ b/dimos/manipulation/planning/world/roboplan_world.py @@ -72,6 +72,7 @@ GeneratedPlan, GeneratedTrajectory, IKResult, + LinearTcpPathConstraint, Obstacle, PlanningGroupID, PlanningResult, @@ -972,17 +973,27 @@ def parametrize( try: self._require_finalized() roboplan_toppra = importlib.import_module("roboplan.toppra") + selection = self._planning_groups.select(plan.group_ids) group_data = self._group_data_for_selection_ids(plan.group_ids) - joint_path = self._generated_plan_to_roboplan_joint_path(plan, group_data) options = self._roboplan_toppra_options(roboplan_toppra, speed_scale=speed_scale) self._set_scene_current_q(self._live_context) - native_trajectory = roboplan_toppra.PathParameterizerTOPPRA( - self._require_scene(), group_data.group_name - ).generate(joint_path, options) - return self._roboplan_trajectory_to_generated( + smoothed = self._maybe_parametrize_smoothed_path( plan, + selection, + group_data, + roboplan_toppra, + options, + speed_scale=speed_scale, + ) + if smoothed is not None: + return smoothed + return self._parametrize_roboplan_path( + plan, + plan.path, + selection, group_data, - native_trajectory, + roboplan_toppra, + options, speed_scale=speed_scale, ) except ImportError: @@ -1030,31 +1041,465 @@ def _trajectory_parametrization_failure( def _generated_plan_to_roboplan_joint_path( self, plan: GeneratedPlan, + selection: PlanningGroupSelection, group_data: _RoboPlanGroupData, ) -> Any: - expected_global_names = [ - group_data.native_to_global_joint_name[native_name] - for native_name in group_data.native_joint_names - ] positions: list[list[float]] = [] for waypoint in plan.path: - if len(waypoint.name) != len(waypoint.position): - raise ValueError("Generated-plan waypoint name and position lengths must match") - positions_by_name = { - name: float(position) - for name, position in zip(waypoint.name, waypoint.position, strict=True) - } - try: - positions.append([positions_by_name[name] for name in expected_global_names]) - except KeyError as exc: - raise ValueError( - f"GeneratedPlan waypoint is missing selected joint '{exc.args[0]}'" - ) from exc + positions_by_global = self._strict_selection_positions_by_global(selection, waypoint) + position = [ + positions_by_global[group_data.native_to_global_joint_name[native_name]] + for native_name in group_data.native_joint_names + ] + if not np.all(np.isfinite(position)): + raise ValueError("Generated-plan waypoint positions must be finite") + positions.append(position) joint_path = roboplan_core.JointPath() joint_path.joint_names = list(group_data.native_joint_names) joint_path.positions = np.asarray(positions, dtype=np.float64) return joint_path + def _parametrize_roboplan_path( + self, + source_plan: GeneratedPlan, + path: list[JointState], + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + roboplan_toppra: Any, + options: Any, + *, + speed_scale: float, + ) -> GeneratedTrajectory: + path_plan = replace(source_plan, path=path) + joint_path = self._generated_plan_to_roboplan_joint_path(path_plan, selection, group_data) + native_trajectory = roboplan_toppra.PathParameterizerTOPPRA( + self._require_scene(), group_data.group_name + ).generate(joint_path, options) + return self._roboplan_trajectory_to_generated( + source_plan, + selection, + group_data, + native_trajectory, + speed_scale=speed_scale, + ) + + def _maybe_parametrize_smoothed_path( + self, + plan: GeneratedPlan, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + roboplan_toppra: Any, + options: Any, + *, + speed_scale: float, + ) -> GeneratedTrajectory | None: + config = self._trajectory_parametrization_config + if not config.roboplan_smoothing_enabled: + return None + if len(plan.path) < config.roboplan_smoothing_min_waypoints: + return None + for candidate_path in self._adaptive_smoothing_candidates(plan.path): + if len(candidate_path) >= len(plan.path): + continue + try: + rejection = self._validate_smoothed_path( + plan, selection, group_data, candidate_path + ) + except Exception as exc: + rejection = f"smoothing candidate validation raised {type(exc).__name__}: {exc}" + if rejection is not None: + logger.info( + "Rejected RoboPlan smoothed path (%d -> %d waypoints): %s", + len(plan.path), + len(candidate_path), + rejection, + ) + continue + try: + trajectory = self._parametrize_roboplan_path( + plan, + candidate_path, + selection, + group_data, + roboplan_toppra, + options, + speed_scale=speed_scale, + ) + except Exception as exc: + logger.info( + "RoboPlan TOPPRA rejected smoothed path (%d -> %d waypoints): %s", + len(plan.path), + len(candidate_path), + exc, + ) + continue + try: + rejection = self._validate_smoothed_trajectory_output( + plan, selection, group_data, candidate_path, trajectory + ) + except Exception as exc: + rejection = f"smoothed trajectory validation raised {type(exc).__name__}: {exc}" + if rejection is not None: + logger.info( + "Rejected RoboPlan smoothed trajectory (%d -> %d waypoints): %s", + len(plan.path), + len(candidate_path), + rejection, + ) + continue + trajectory.message = ( + f"{trajectory.message}; smoothed RoboPlan path " + f"{len(plan.path)} -> {len(candidate_path)} waypoints" + ) + return trajectory + return None + + def _adaptive_smoothing_candidates(self, path: list[JointState]) -> list[list[JointState]]: + config = self._trajectory_parametrization_config + waypoint_count = len(path) + min_keep = max(2, ceil(waypoint_count * config.roboplan_smoothing_min_keep_fraction)) + max_keep = max(min_keep, waypoint_count - 1) + if min_keep >= waypoint_count: + return [] + if config.roboplan_smoothing_max_attempts == 1: + keep_counts = [min_keep] + else: + keep_counts = [ + round(value) + for value in np.linspace( + min_keep, + max_keep, + config.roboplan_smoothing_max_attempts, + ) + ] + candidates: list[list[JointState]] = [] + seen_counts: set[int] = set() + for keep_count in keep_counts: + keep_count = min(max(2, keep_count), waypoint_count - 1) + if keep_count in seen_counts: + continue + seen_counts.add(keep_count) + indices = sorted( + {round(index) for index in np.linspace(0, waypoint_count - 1, keep_count)} + | {0, waypoint_count - 1} + ) + candidates.append([path[index] for index in indices]) + return candidates + + def _validate_smoothed_trajectory_output( + self, + source_plan: GeneratedPlan, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + candidate_path: list[JointState], + trajectory: GeneratedTrajectory, + ) -> str | None: + if not trajectory.is_success(): + return trajectory.message or "smoothed trajectory parametrization failed" + path = [ + JointState( + name=list(trajectory.joint_names), + position=[float(value) for value in point.positions], + ) + for point in trajectory.points + ] + if len(path) < 2: + return "smoothed trajectory output has fewer than two points" + config = self._trajectory_parametrization_config + try: + output_positions = self._strict_selection_positions_matrix(selection, path) + candidate_positions = self._strict_selection_positions_matrix(selection, candidate_path) + except ValueError as exc: + return str(exc) + output_deviation = self._max_joint_deviation_from_polyline( + output_positions, candidate_positions + ) + if output_deviation > config.roboplan_smoothing_max_joint_deviation: + return ( + "smoothed trajectory output deviates from accepted candidate by " + f"{output_deviation:.6f}, exceeding " + f"{config.roboplan_smoothing_max_joint_deviation:.6f}" + ) + return self._validate_smoothed_path(source_plan, selection, group_data, path) + + def _validate_smoothed_path( + self, + source_plan: GeneratedPlan, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + candidate_path: list[JointState], + ) -> str | None: + config = self._trajectory_parametrization_config + try: + original_positions = self._strict_selection_positions_matrix( + selection, source_plan.path + ) + candidate_positions = self._strict_selection_positions_matrix(selection, candidate_path) + self._strict_selection_native_positions_matrix(selection, group_data, candidate_path) + except ValueError as exc: + return str(exc) + if len(candidate_path) < 2: + return "candidate path must contain at least two waypoints" + if not np.allclose(candidate_positions[0], original_positions[0], atol=1e-9): + return "candidate path does not preserve the original start state" + if not np.allclose(candidate_positions[-1], original_positions[-1], atol=1e-9): + return "candidate path does not preserve the original goal state" + limits_error = self._validate_selection_path_within_limits( + selection, group_data, candidate_path + ) + if limits_error is not None: + return limits_error + deviation = self._max_joint_deviation_from_polyline(original_positions, candidate_positions) + if deviation > config.roboplan_smoothing_max_joint_deviation: + return ( + "candidate path deviates from original joint path by " + f"{deviation:.6f}, exceeding " + f"{config.roboplan_smoothing_max_joint_deviation:.6f}" + ) + if not self._selection_path_collision_free( + selection, candidate_path, step_size=config.roboplan_smoothing_edge_step_size + ): + return "candidate path is in collision" + path_constraints = source_plan.path_constraints + if isinstance(path_constraints, LinearTcpPathConstraint): + return self._validate_linear_tcp_constraint(selection, candidate_path, path_constraints) + return None + + def _strict_selection_positions_matrix( + self, selection: PlanningGroupSelection, path: list[JointState] + ) -> NDArray[np.float64]: + expected_names = tuple(selection.joint_names) + positions: list[list[float]] = [] + for waypoint in path: + positions_by_global = self._strict_selection_positions_by_global(selection, waypoint) + positions.append([positions_by_global[name] for name in expected_names]) + return np.asarray(positions, dtype=np.float64) + + def _strict_selection_positions_by_global( + self, selection: PlanningGroupSelection, waypoint: JointState + ) -> dict[str, float]: + expected_names = tuple(selection.joint_names) + if tuple(waypoint.name) != expected_names: + raise ValueError( + "GeneratedPlan waypoint names must exactly match selected global joints" + ) + if len(waypoint.position) != len(expected_names): + raise ValueError("GeneratedPlan waypoint position count must match selected joints") + values = [float(value) for value in waypoint.position] + if not np.all(np.isfinite(values)): + raise ValueError("GeneratedPlan waypoint positions must be finite") + return dict(zip(expected_names, values, strict=True)) + + def _strict_selection_native_positions_matrix( + self, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + path: list[JointState], + ) -> NDArray[np.float64]: + rows: list[list[float]] = [] + for waypoint in path: + positions_by_global = self._selection_positions_by_global(selection, waypoint) + rows.append( + [ + positions_by_global[group_data.native_to_global_joint_name[native_name]] + for native_name in group_data.native_joint_names + ] + ) + return np.asarray(rows, dtype=np.float64) + + def _validate_selection_path_within_limits( + self, + selection: PlanningGroupSelection, + group_data: _RoboPlanGroupData, + path: list[JointState], + ) -> str | None: + lower_limits, upper_limits = self._selection_native_limits(selection, group_data) + native_positions = self._strict_selection_native_positions_matrix( + selection, group_data, path + ) + if np.any(native_positions < lower_limits - 1e-9) or np.any( + native_positions > upper_limits + 1e-9 + ): + return "candidate path violates selected joint limits" + return None + + def _max_joint_deviation_from_polyline( + self, + original_positions: NDArray[np.float64], + candidate_positions: NDArray[np.float64], + ) -> float: + max_deviation = 0.0 + for point in original_positions: + min_distance = min( + self._point_to_segment_distance(point, start, end) + for start, end in pairwise(candidate_positions) + ) + max_deviation = max(max_deviation, min_distance) + return max_deviation + + def _point_to_segment_distance( + self, + point: NDArray[np.float64], + start: NDArray[np.float64], + end: NDArray[np.float64], + ) -> float: + segment = end - start + length_squared = float(np.dot(segment, segment)) + if length_squared <= 1e-16: + return float(np.linalg.norm(point - start)) + alpha = float(np.clip(np.dot(point - start, segment) / length_squared, 0.0, 1.0)) + projection = start + alpha * segment + return float(np.linalg.norm(point - projection)) + + def _validate_linear_tcp_constraint( + self, + selection: PlanningGroupSelection, + path: list[JointState], + constraint: LinearTcpPathConstraint, + ) -> str | None: + if constraint.group_id not in selection.group_ids: + return f"linear TCP constraint group '{constraint.group_id}' is not selected" + if constraint.start_pose is None or constraint.target_pose is None: + return "linear TCP constraint requires start_pose and target_pose" + metadata_error = self._validate_linear_tcp_constraint_metadata(selection, constraint) + if metadata_error is not None: + return metadata_error + config = self._trajectory_parametrization_config + with self.scratch_context() as ctx: + for joint_state in self._sample_selection_path( + selection, path, config.roboplan_smoothing_edge_step_size + ): + self._set_selection_state(ctx, selection, joint_state) + pose = self.get_group_ee_pose(ctx, constraint.group_id) + position_error, orientation_error = self._linear_tcp_pose_errors(pose, constraint) + if position_error > constraint.max_translational_deviation: + return ( + "linear TCP candidate deviates from line by " + f"{position_error:.6f}, exceeding " + f"{constraint.max_translational_deviation:.6f}" + ) + if orientation_error > constraint.max_rotational_deviation: + return ( + "linear TCP candidate orientation deviates by " + f"{orientation_error:.6f}, exceeding " + f"{constraint.max_rotational_deviation:.6f}" + ) + return None + + def _validate_linear_tcp_constraint_metadata( + self, + selection: PlanningGroupSelection, + constraint: LinearTcpPathConstraint, + ) -> str | None: + group = next( + ( + selected_group + for selected_group in selection.groups + if selected_group.id == constraint.group_id + ), + None, + ) + if group is None: + return f"linear TCP constraint group '{constraint.group_id}' is not selected" + expected_tcp_frame = group.tip_link or "" + if constraint.tcp_frame != expected_tcp_frame: + return ( + "linear TCP constraint tcp_frame must match selected group tip; " + f"expected '{expected_tcp_frame}', got '{constraint.tcp_frame}'" + ) + assert constraint.start_pose is not None + assert constraint.target_pose is not None + if constraint.start_pose.frame_id != "world" or constraint.target_pose.frame_id != "world": + return "linear TCP constraint start_pose and target_pose must be in world frame" + tolerances = ( + constraint.max_translational_deviation, + constraint.max_rotational_deviation, + ) + if any(not np.isfinite(tolerance) or tolerance < 0.0 for tolerance in tolerances): + return "linear TCP constraint tolerances must be finite and nonnegative" + return None + + def _sample_selection_path( + self, + selection: PlanningGroupSelection, + path: list[JointState], + step_size: float, + ) -> list[JointState]: + if len(path) < 2: + return path + samples: list[JointState] = [] + for start, end in pairwise(path): + start_by_global = self._selection_positions_by_global(selection, start) + end_by_global = self._selection_positions_by_global(selection, end) + q_start = np.asarray( + [start_by_global[name] for name in selection.joint_names], dtype=np.float64 + ) + q_end = np.asarray( + [end_by_global[name] for name in selection.joint_names], dtype=np.float64 + ) + distance = float(np.linalg.norm(q_end - q_start)) + n_steps = max(2, ceil(distance / step_size) + 1) + for index in range(n_steps): + if samples and index == 0: + continue + alpha = index / (n_steps - 1) + q = q_start + alpha * (q_end - q_start) + samples.append( + JointState( + name=list(selection.joint_names), + position=[float(value) for value in q], + ) + ) + return samples + + def _linear_tcp_pose_errors( + self, + pose: PoseStamped, + constraint: LinearTcpPathConstraint, + ) -> tuple[float, float]: + assert constraint.start_pose is not None + assert constraint.target_pose is not None + current_matrix = pose_to_matrix(pose) + start_matrix = pose_to_matrix(constraint.start_pose) + target_matrix = pose_to_matrix(constraint.target_pose) + alpha = self._linear_tcp_alpha(current_matrix, start_matrix, target_matrix) + expected_matrix = np.eye(4, dtype=np.float64) + expected_matrix[:3, 3] = start_matrix[:3, 3] + alpha * ( + target_matrix[:3, 3] - start_matrix[:3, 3] + ) + slerp = Slerp([0.0, 1.0], R.from_matrix([start_matrix[:3, :3], target_matrix[:3, :3]])) + expected_matrix[:3, :3] = slerp([alpha]).as_matrix()[0] + return compute_pose_error(current_matrix, expected_matrix) + + def _linear_tcp_alpha( + self, + current_matrix: NDArray[np.float64], + start_matrix: NDArray[np.float64], + target_matrix: NDArray[np.float64], + ) -> float: + translation_delta = target_matrix[:3, 3] - start_matrix[:3, 3] + translation_distance_squared = float(np.dot(translation_delta, translation_delta)) + if translation_distance_squared > 1e-16: + return float( + np.clip( + np.dot(current_matrix[:3, 3] - start_matrix[:3, 3], translation_delta) + / translation_distance_squared, + 0.0, + 1.0, + ) + ) + start_rotation = R.from_matrix(start_matrix[:3, :3]) + target_rotation = R.from_matrix(target_matrix[:3, :3]) + current_rotation = R.from_matrix(current_matrix[:3, :3]) + total_rotvec = (target_rotation * start_rotation.inv()).as_rotvec() + total_distance_squared = float(np.dot(total_rotvec, total_rotvec)) + if total_distance_squared <= 1e-16: + return 0.0 + current_rotvec = (current_rotation * start_rotation.inv()).as_rotvec() + return float( + np.clip(np.dot(current_rotvec, total_rotvec) / total_distance_squared, 0.0, 1.0) + ) + def _roboplan_toppra_options(self, roboplan_toppra: Any, *, speed_scale: float) -> Any: config = self._trajectory_parametrization_config options = roboplan_toppra.TOPPRAOptions() @@ -1079,6 +1524,7 @@ def _roboplan_toppra_options(self, roboplan_toppra: Any, *, speed_scale: float) def _roboplan_trajectory_to_generated( self, plan: GeneratedPlan, + selection: PlanningGroupSelection, group_data: _RoboPlanGroupData, native_trajectory: Any, *, @@ -1118,6 +1564,30 @@ def _roboplan_trajectory_to_generated( if np.any(~np.isfinite(positions)) or np.any(~np.isfinite(velocities)): raise ValueError("RoboPlan trajectory contains non-finite values") + native_indices_by_global = { + global_name: index for index, global_name in enumerate(global_joint_names) + } + selected_names = tuple(selection.joint_names) + selected_name_set = set(selected_names) + missing_selected = [ + global_name + for global_name in selected_names + if global_name not in native_indices_by_global + ] + extra_returned = [ + global_name + for global_name in global_joint_names + if global_name not in selected_name_set + ] + if missing_selected or extra_returned: + raise ValueError( + "RoboPlan trajectory joint_names must match selected global joints; " + f"missing={missing_selected}, extra={extra_returned}" + ) + selected_indices = [native_indices_by_global[name] for name in selected_names] + positions = positions[:, selected_indices] + velocities = velocities[:, selected_indices] + points = [ TrajectoryPoint( time_from_start=float(time_from_start), @@ -1129,7 +1599,7 @@ def _roboplan_trajectory_to_generated( ) ] return GeneratedTrajectory( - joint_names=global_joint_names, + joint_names=list(selected_names), points=points, duration=float(times[-1]), speed_scale=speed_scale, @@ -1348,12 +1818,25 @@ def _plan_linear_cartesian_path( planning_time=time.time() - start_time, message="RoboPlan linear Cartesian planning failed: final TCP pose missed target", ) + target_pose_world = PoseStamped( + frame_id="world", + position=target_pose.position, + orientation=target_pose.orientation, + ) return PlanningResult( status=PlanningStatus.SUCCESS, path=path, planning_time=time.time() - start_time, path_length=compute_path_length(path), message="RoboPlan linear Cartesian path found", + path_constraints=LinearTcpPathConstraint( + group_id=group_id, + tcp_frame=group.tip_link or "", + start_pose=start_pose, + target_pose=target_pose_world, + max_translational_deviation=_CARTESIAN_POSITION_TOLERANCE, + max_rotational_deviation=_CARTESIAN_ORIENTATION_TOLERANCE, + ), ) def _solve_linear_oink_waypoint( diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 038e969523..0236430329 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -47,6 +47,7 @@ GeneratedPlan, GeneratedTrajectory, IKResult, + LinearTcpPathConstraint, PlanningResult, PlanningSceneInfo, ) @@ -154,6 +155,29 @@ def _successful_generated_trajectory() -> GeneratedTrajectory: ) +def test_store_generated_plan_preserves_path_constraints() -> None: + module = _make_module() + constraint = LinearTcpPathConstraint(group_id="test_arm/manipulator", tcp_frame="link_tcp") + path = [JointState(name=["test_arm/joint1"], position=[0.1])] + result = PlanningResult( + status=PlanningStatus.SUCCESS, + path=path, + planning_time=0.2, + path_length=0.1, + iterations=3, + message="planned", + path_constraints=constraint, + ) + + module._store_generated_plan(("test_arm/manipulator",), result) + + assert module._last_plan is not None + assert module._last_plan.path_constraints is constraint + assert module._last_plan.path is path + assert module._last_plan.message == "planned" + assert module._last_trajectory is None + + class TestTrajectoryDispatchPreparation: """Test projection from global generated trajectories to coordinator tasks.""" diff --git a/dimos/manipulation/test_roboplan_world.py b/dimos/manipulation/test_roboplan_world.py index 8463f60105..593d24e09d 100644 --- a/dimos/manipulation/test_roboplan_world.py +++ b/dimos/manipulation/test_roboplan_world.py @@ -25,6 +25,7 @@ import numpy as np import pytest +from pytest_mock import MockerFixture from dimos.manipulation.planning.groups.models import ( PlanningGroupDefinition, @@ -42,12 +43,15 @@ from dimos.manipulation.planning.spec.models import ( CartesianDelta, GeneratedPlan, + GeneratedTrajectory, + LinearTcpPathConstraint, Obstacle, ) from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.msgs.trajectory_msgs.TrajectoryPoint import TrajectoryPoint from dimos.utils.transform_utils import pose_to_matrix @@ -598,6 +602,256 @@ def test_roboplan_world_parametrizes_generated_plan_with_native_toppra( assert FakePathParameterizerTOPPRA.last_options.acceleration_scale == pytest.approx(0.1) +def test_roboplan_parametrization_maps_selected_order_to_native_and_back( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + selection_key = frozenset(("arm/manipulator",)) + group_data = world._roboplan_groups_by_selection[selection_key] + world._roboplan_groups_by_selection[selection_key] = type(group_data)( + group_ids=group_data.group_ids, + group_name=group_data.group_name, + native_joint_names=("joint2", "joint1"), + native_to_global_joint_name=group_data.native_to_global_joint_name, + ) + world.configure_trajectory_parametrization( + TrajectoryParametrizationConfig(backend="roboplan", roboplan_smoothing_enabled=False) + ) + plan = GeneratedPlan( + group_ids=("arm/manipulator",), + path=[ + JointState(name=["arm/joint1", "arm/joint2"], position=[0.1, 0.2]), + JointState(name=["arm/joint1", "arm/joint2"], position=[0.3, 0.4]), + ], + status=PlanningStatus.SUCCESS, + ) + + trajectory = world.parametrize(plan) + + assert trajectory.status == ParametrizationStatus.SUCCESS + assert FakePathParameterizerTOPPRA.last_path is not None + assert FakePathParameterizerTOPPRA.last_path.joint_names == ["joint2", "joint1"] + np.testing.assert_allclose( + FakePathParameterizerTOPPRA.last_path.positions, + [[0.2, 0.1], [0.4, 0.3]], + ) + assert trajectory.joint_names == ["arm/joint1", "arm/joint2"] + assert [point.positions for point in trajectory.points] == [[0.1, 0.2], [0.3, 0.4]] + + +def _long_generated_plan(waypoint_count: int = 12) -> GeneratedPlan: + return GeneratedPlan( + group_ids=("arm/manipulator",), + path=[ + JointState( + name=["arm/joint1", "arm/joint2"], + position=[float(index) * 0.01, float(index) * 0.02], + ) + for index in range(waypoint_count) + ], + status=PlanningStatus.SUCCESS, + message="source plan", + ) + + +def test_roboplan_parametrization_smoothing_reduces_long_paths_by_default( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + world.configure_trajectory_parametrization(TrajectoryParametrizationConfig(backend="roboplan")) + plan = _long_generated_plan() + + trajectory = world.parametrize(plan) + + assert trajectory.status == ParametrizationStatus.SUCCESS + assert FakePathParameterizerTOPPRA.last_path is not None + assert len(FakePathParameterizerTOPPRA.last_path.positions) < len(plan.path) + assert trajectory.source_group_ids == plan.group_ids + assert trajectory.source_plan_message == "source plan" + assert "smoothed RoboPlan path" in trajectory.message + + +def test_roboplan_parametrization_uses_original_count_when_smoothing_disabled_or_short( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + world.configure_trajectory_parametrization( + TrajectoryParametrizationConfig(backend="roboplan", roboplan_smoothing_enabled=False) + ) + disabled_plan = _long_generated_plan() + + disabled_trajectory = world.parametrize(disabled_plan) + + assert disabled_trajectory.status == ParametrizationStatus.SUCCESS + assert FakePathParameterizerTOPPRA.last_path is not None + assert len(FakePathParameterizerTOPPRA.last_path.positions) == len(disabled_plan.path) + + world.configure_trajectory_parametrization(TrajectoryParametrizationConfig(backend="roboplan")) + short_plan = _long_generated_plan(waypoint_count=3) + short_trajectory = world.parametrize(short_plan) + + assert short_trajectory.status == ParametrizationStatus.SUCCESS + assert FakePathParameterizerTOPPRA.last_path is not None + assert len(FakePathParameterizerTOPPRA.last_path.positions) == len(short_plan.path) + + +def test_roboplan_parametrization_falls_back_when_smoothed_toppra_fails( + fake_roboplan: None, robot_config: RobotModelConfig, mocker: MockerFixture +) -> None: + original_generate = FakePathParameterizerTOPPRA.generate + + def fail_smoothed_once( + self: FakePathParameterizerTOPPRA, path: FakeJointPath, options: FakeTOPPRAOptions + ) -> FakeJointTrajectory: + if len(path.positions) < 12: + raise RuntimeError("smoothed rejected") + return original_generate(self, path, options) + + mocker.patch.object( + FakePathParameterizerTOPPRA, + "generate", + autospec=True, + side_effect=fail_smoothed_once, + ) + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + world.configure_trajectory_parametrization(TrajectoryParametrizationConfig(backend="roboplan")) + plan = _long_generated_plan() + + trajectory = world.parametrize(plan) + + assert trajectory.status == ParametrizationStatus.SUCCESS + assert FakePathParameterizerTOPPRA.last_path is not None + assert len(FakePathParameterizerTOPPRA.last_path.positions) == len(plan.path) + assert "smoothed RoboPlan path" not in trajectory.message + + +def test_roboplan_parametrization_falls_back_when_candidate_validation_fails( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + world.configure_trajectory_parametrization( + TrajectoryParametrizationConfig( + backend="roboplan", + roboplan_smoothing_max_joint_deviation=0.0, + ) + ) + plan = _long_generated_plan() + for index, state in enumerate(plan.path): + state.position = [float(index) * 0.01, 0.05 if index % 2 else 0.0] + + trajectory = world.parametrize(plan) + + assert trajectory.status == ParametrizationStatus.SUCCESS + assert FakePathParameterizerTOPPRA.last_path is not None + assert len(FakePathParameterizerTOPPRA.last_path.positions) == len(plan.path) + + +def test_roboplan_parametrization_falls_back_when_smoothing_validation_raises( + fake_roboplan: None, robot_config: RobotModelConfig, mocker: MockerFixture +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + world.configure_trajectory_parametrization(TrajectoryParametrizationConfig(backend="roboplan")) + mocker.patch.object( + world, + "_validate_smoothed_path", + side_effect=RuntimeError("validation exploded"), + ) + plan = _long_generated_plan() + + trajectory = world.parametrize(plan) + + assert trajectory.status == ParametrizationStatus.SUCCESS + assert FakePathParameterizerTOPPRA.last_path is not None + assert len(FakePathParameterizerTOPPRA.last_path.positions) == len(plan.path) + + +def test_smoothed_trajectory_output_rejects_output_deviation_from_candidate( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + world.configure_trajectory_parametrization( + TrajectoryParametrizationConfig( + backend="roboplan", + roboplan_smoothing_max_joint_deviation=0.02, + ) + ) + selection = _default_selection(world, robot_config) + group_data = world._group_data_for_selection_ids(("arm/manipulator",)) + source_plan = _long_generated_plan() + candidate_path = [source_plan.path[0], source_plan.path[-1]] + trajectory = GeneratedTrajectory( + joint_names=["arm/joint1", "arm/joint2"], + points=[ + TrajectoryPoint(time_from_start=0.0, positions=[0.0, 0.0]), + TrajectoryPoint(time_from_start=0.5, positions=[0.05, 1.0]), + TrajectoryPoint(time_from_start=1.0, positions=[0.11, 0.22]), + ], + status=ParametrizationStatus.SUCCESS, + ) + + rejection = world._validate_smoothed_trajectory_output( + source_plan, selection, group_data, candidate_path, trajectory + ) + + assert rejection is not None + assert "deviates from accepted candidate" in rejection + + +def test_linear_tcp_validation_rejects_inconsistent_metadata( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, _robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + world.configure_trajectory_parametrization(TrajectoryParametrizationConfig(backend="roboplan")) + selection = _default_selection(world, robot_config) + path = _long_generated_plan(waypoint_count=2).path + start_pose = PoseStamped(frame_id="world", position=Vector3(), orientation=Quaternion()) # type: ignore[call-arg] + target_pose = PoseStamped(frame_id="world", position=Vector3(x=0.1), orientation=Quaternion()) # type: ignore[call-arg] + + wrong_frame = world._validate_linear_tcp_constraint( + selection, + path, + LinearTcpPathConstraint( + group_id="arm/manipulator", + tcp_frame="wrong_tcp", + start_pose=start_pose, + target_pose=target_pose, + ), + ) + non_world_start = world._validate_linear_tcp_constraint( + selection, + path, + LinearTcpPathConstraint( + group_id="arm/manipulator", + tcp_frame="tcp", + start_pose=PoseStamped(frame_id="base", position=Vector3(), orientation=Quaternion()), # type: ignore[call-arg] + target_pose=target_pose, + ), + ) + invalid_tolerance = world._validate_linear_tcp_constraint( + selection, + path, + LinearTcpPathConstraint( + group_id="arm/manipulator", + tcp_frame="tcp", + start_pose=start_pose, + target_pose=target_pose, + max_translational_deviation=-1.0, + ), + ) + + assert wrong_frame is not None and "tcp_frame" in wrong_frame + assert non_world_start is not None and "world frame" in non_world_start + assert invalid_tolerance is not None and "finite and nonnegative" in invalid_tolerance + + def test_robot_registration_finalization_and_joint_limits( fake_roboplan: None, robot_config: RobotModelConfig ) -> None: @@ -1067,6 +1321,7 @@ def test_generic_rrt_planner_uses_roboplan_world_collision_checks( assert result.status == PlanningStatus.SUCCESS assert len(result.path) >= 2 + assert result.path_constraints is None def test_group_fk_jacobian_and_explicit_min_distance_unsupported( @@ -1180,6 +1435,7 @@ def test_cartesian_free_absolute_uses_simple_ik_then_rrt( assert FakeRRT.last_start is not None np.testing.assert_allclose(FakeRRT.last_start.positions, [0.1, 0.1]) np.testing.assert_allclose(world._scene.current_positions, [0.1, 0.1]) + assert result.path_constraints is None def test_cartesian_free_relative_delta_uses_world_axes( @@ -1412,6 +1668,17 @@ def test_cartesian_linear_mode_supports_single_absolute_target( ) assert {goal.base_frame for goal in FakeFrameTask.targets} == {""} assert {goal.tip_frame for goal in FakeFrameTask.targets} == {"tcp"} + assert isinstance(result.path_constraints, LinearTcpPathConstraint) + assert result.path_constraints.group_id == "arm/manipulator" + assert result.path_constraints.tcp_frame == "tcp" + assert result.path_constraints.start_pose is not None + assert result.path_constraints.target_pose is not None + assert result.path_constraints.start_pose.frame_id == "world" + assert result.path_constraints.target_pose.frame_id == "world" + assert result.path_constraints.start_pose.position.x == pytest.approx(0.0) + assert result.path_constraints.target_pose.position.x == pytest.approx(0.04) + assert result.path_constraints.max_translational_deviation == pytest.approx(1e-3) + assert result.path_constraints.max_rotational_deviation == pytest.approx(1e-2) def test_cartesian_linear_mode_sets_selected_group_state_by_group_name( @@ -1521,6 +1788,15 @@ def test_cartesian_linear_mode_supports_single_relative_target( assert goal_xs[0] > 0.2 assert goal_xs == sorted(goal_xs) assert goal_xs[-1] == pytest.approx(0.24) + assert isinstance(result.path_constraints, LinearTcpPathConstraint) + assert result.path_constraints.group_id == "arm/manipulator" + assert result.path_constraints.tcp_frame == "tcp" + assert result.path_constraints.start_pose is not None + assert result.path_constraints.target_pose is not None + assert result.path_constraints.start_pose.frame_id == "world" + assert result.path_constraints.target_pose.frame_id == "world" + assert result.path_constraints.start_pose.position.x == pytest.approx(0.2) + assert result.path_constraints.target_pose.position.x == pytest.approx(0.24) def test_cartesian_linear_mode_rejects_multi_target_without_free_fallback( diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index 8c2403ab2f..b1567f148f 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -196,6 +196,16 @@ RoboPlan builds one generated Composite RoboPlan model for the registered arms, keeps DimOS public joint names in `robot/joint` form, and returns planned paths in the caller's selected planning-group order. +When RoboPlan trajectory parametrization is selected, conservative smoothing is +enabled by default for eligible dense paths. The smoothing stage validates any +refined path before TOPP-RA and falls back to the original geometric path if +smoothing or validation fails, so the expected worst case is a slower valid +trajectory rather than a failed preview. Disable it for debugging with: + +```bash +-o manipulationmodule.trajectory_parametrization.roboplan_smoothing_enabled=false +``` + ## Supported Robots | Robot | DOF | Teleop | Planning | Perception | diff --git a/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/.openspec.yaml b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/design.md b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/design.md new file mode 100644 index 0000000000..c13ffe2bab --- /dev/null +++ b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/design.md @@ -0,0 +1,106 @@ +## Context + +Linear TCP planning currently samples a straight Cartesian segment into dense IK waypoints and returns those samples as the geometric `GeneratedPlan` path. RoboPlan TOPP-RA consumes a positions-only `JointPath`; public RoboPlan behavior and local observations indicate dense intermediate waypoints can behave like stop-like knots. This makes valid linear TCP motions much slower than comparable free-space plans even when velocity and acceleration scales are already at their maximum allowed values. + +The agreed product direction is to keep the practical implementation under RoboPlan trajectory parametrization for now, but structure the internals like a future-splittable trajectory post-processing pipeline. Smoothing is enabled by default for RoboPlan, conservative, validated before use, and non-blocking: if smoothing cannot produce an accepted path, parametrization falls back to the original geometric path. + +## Goals / Non-Goals + +**Goals:** + +- Add minimal optional path-constraint metadata to geometric plan artifacts. +- Preserve linear TCP intent by carrying TCP-line constraints from linear Cartesian planning into trajectory post-processing. +- Introduce a staged RoboPlan post-processing pipeline inside the existing parametrization backend boundary. +- Provide conservative default smoothing that reduces dense stop-like waypoints when validation accepts the refined path. +- Make smoothing failure non-blocking: fall back to original path and preserve the existing slow-but-valid behavior. +- Keep the structure easy to split later into independent post-processing, validation, retiming, and execution smoothing stages. + +**Non-Goals:** + +- Do not build a full Tesseract/TrajOpt/Drake-style constrained trajectory optimizer in this change. +- Do not add Ruckig or another jerk-limited external dependency in this change. +- Do not guarantee constant TCP speed along linear paths in this change. +- Do not alter the public planner method signatures unless needed for metadata propagation. +- Do not make smoothing a correctness gate for plan validity. + +## Decisions + +### Keep the facade as trajectory parametrization, but model internals as a pipeline + +RoboPlan smoothing will run from `RoboPlanWorld.parametrize(...)` through internal pipeline-style stages before TOPP-RA conversion. The first implementation can remain private to the RoboPlan backend, but the code should separate stage responsibilities: input interpretation, preprocessing, validation, retiming, conversion, and fallback. + +Alternatives considered: + +- Put smoothing directly inside linear TCP planning. Rejected because mature stacks separate geometric planning from post-processing/retiming, and smoothing should also be available for other dense geometric paths. +- Add a top-level post-processing abstraction immediately. Deferred because the current implementation only needs the RoboPlan backend and the practical boundary should stay under parametrization for now. + +### Add optional path-constraint metadata to plan artifacts + +`GeneratedPlan` should gain an optional metadata field that declares constraints downstream post-processing must preserve. `PlanningResult` should carry the same optional metadata so planners can return it and `ManipulationModule` can preserve it when constructing `GeneratedPlan`. + +The initial constraint model should be minimal and typed. For linear TCP it should identify the constrained planning group/TCP frame and the line segment from start pose to target pose, plus translational and rotational tolerance values used for validation. + +Alternatives considered: + +- Infer linear constraints from path shape. Rejected because inference is brittle and cannot distinguish intentional straight-line constraints from merely dense joint-space samples. +- Delay metadata. Rejected because production-safe smoothing for linear TCP needs explicit Cartesian validation, not only joint-space deviation checks. + +### Validate refined paths before retiming them + +The pipeline should only accept a refined geometric path if validation succeeds. Baseline validation should cover endpoints, joint limits, collision, waypoint count/order sanity, and maximum joint-space deviation from the original path. When path-constraint metadata is present, validation must also check those declared constraints, including linear TCP Cartesian deviation for linear TCP plans. + +Alternatives considered: + +- Trust waypoint simplification because it only selects original waypoints. Rejected because reducing waypoints can change the interpolated path that TOPP-RA/spline fitting follows between retained knots. +- Validate only after time parametrization. Rejected as the first gate because geometric invalidity should be detected before relying on retimed trajectories. + +### Start with adaptive waypoint simplification as one pipeline stage + +The first concrete preprocessing stage should reduce dense waypoint chains by preserving endpoints and adaptively selecting fewer original waypoints. If validation fails, the stage retries with a more conservative selection that preserves more waypoints. It must not invent new IK states in the first version. + +This is intentionally only a stage, not the architecture. Future stages may add spline fitting, IK refinement, or constraint-aware optimization after the same validation contract exists. + +Alternatives considered: + +- Fit new joint-space splines immediately. Deferred because generated joint states can silently violate Cartesian path and collision constraints unless paired with stronger validation and resampling. +- Leave all waypoints intact and rely on RoboPlan spline modes. Rejected because all current modes remain slow on dense linear TCP paths. + +### Smoothing failure is non-blocking + +If every smoothing attempt fails validation or preprocessing raises a recoverable error, the backend must parametrize the original path. The original path can still fail if RoboPlan TOPP-RA itself cannot parametrize it, but smoothing failure alone must not fail the plan. + +Alternatives considered: + +- Strict smoothing mode. Rejected for default behavior because operators need continuity and the worst expected smoothing outcome is the current slow trajectory. + +### Enable RoboPlan smoothing by default, conservatively + +RoboPlan smoothing should be enabled by default with conservative thresholds. Configuration should allow disabling smoothing and tuning attempts, minimum waypoint count, and deviation tolerances, but the default should preserve correctness and fallback behavior. + +Alternatives considered: + +- Opt-in first. Rejected because the user-facing problem is the default behavior of linear TCP plans crawling despite full speed settings. + +## Risks / Trade-offs + +- Refined path violates linear TCP intent → Mitigation: require path-constraint metadata for linear TCP plans and validate Cartesian deviation before accepting the refined path. +- Refined path passes sparse validation but collides between samples → Mitigation: collision-check retained/refined samples now and keep validation structure ready for denser resampling checks; fallback to original path on failure. +- Conservative defaults provide little speedup → Mitigation: make attempts and tolerances configurable while preserving correctness tolerances. +- Internal pipeline becomes a permanent hidden abstraction → Mitigation: keep stage boundaries and names explicit so future work can split it out without redesigning behavior. +- Additional metadata creates model churn across tests → Mitigation: make metadata optional and default to `None`, so existing free-space plans remain valid. + +## Migration Plan + +1. Add optional metadata/config model fields with safe defaults. +2. Propagate metadata from `PlanningResult` to `GeneratedPlan` without changing existing no-metadata behavior. +3. Attach linear TCP constraints in RoboPlan linear Cartesian planning results. +4. Add RoboPlan post-processing pipeline internals behind the existing parametrization backend. +5. Validate and fallback on smoothing failure before calling TOPP-RA on the accepted path. +6. Update tests to cover metadata propagation, linear TCP validation, smoothing acceptance, adaptive retry, and fallback to the original path. + +Rollback is straightforward: disable RoboPlan smoothing in configuration or remove the preprocessing stage while leaving optional metadata harmlessly unused. + +## Open Questions + +- Exact default tolerance values should be chosen from existing linear TCP tolerances where possible, or conservatively introduced in config if no single source exists. +- Whether future hardware execution should add a final jerk-limited smoothing stage remains out of scope for this change. diff --git a/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/proposal.md b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/proposal.md new file mode 100644 index 0000000000..3e600bd4b0 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/proposal.md @@ -0,0 +1,32 @@ +## Why + +RoboPlan-backed linear TCP plans currently produce dense waypoint chains that trajectory parametrization treats like stop-like knots, making valid linear motions much slower than comparable free-space plans even at full speed. We need a production-ready path post-processing structure that can smooth valid plans without weakening correctness, while keeping existing slow-but-valid behavior as the fallback. + +## What Changes + +- Add minimal optional path-constraint metadata to geometric manipulation plans so post-processing can preserve declared constraints instead of relying only on joint-space closeness. +- Attach linear TCP path constraints to successful linear TCP plans, including the constrained TCP frame and configured Cartesian tolerance. +- Extend RoboPlan trajectory parametrization with an internal staged trajectory post-processing pipeline that is easy to split out later. +- Enable conservative RoboPlan smoothing by default, with adaptive-conservative retries and validation before accepting a refined path. +- Preserve non-blocking smoothing fallback: smoothing failure MUST fall back to the original geometric path rather than failing parametrization by itself. +- Keep current public trajectory parametrization entry points and the practical implementation location under RoboPlan trajectory parametrization for this change. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `manipulation-trajectory-parametrization`: Add RoboPlan post-processing pipeline, smoothing defaults, validation, and non-blocking fallback requirements. +- `manipulation-linear-tcp-planning`: Require successful linear TCP plans to carry path-constraint metadata that declares the TCP-line constraint post-processing must preserve. +- `roboplan-cartesian-planning`: Require RoboPlan linear Cartesian planning results to provide enough constraint metadata for downstream trajectory post-processing to validate straight-line TCP preservation. + +## Impact + +- Affected models/specs: `GeneratedPlan`, planning/path constraint models, `TrajectoryParametrizationConfig`, and RoboPlan parametrization support. +- Affected planning code: `dimos/manipulation/planning/world/roboplan_world.py`, Cartesian/linear planning result construction, and trajectory parametrizer conversion to RoboPlan `JointPath`. +- Affected orchestration code: manipulation module plan storage/parametrization paths that pass `GeneratedPlan` through to trajectory generation. +- Affected tests: RoboPlan trajectory parametrization tests, linear TCP planning tests, manipulation module tests, and Viser preview/execution tests that rely on generated trajectories. +- No breaking public command/API behavior is intended; worst-case smoothing failure should preserve existing slow trajectory behavior. diff --git a/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/manipulation-linear-tcp-planning/spec.md b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/manipulation-linear-tcp-planning/spec.md new file mode 100644 index 0000000000..25091daa41 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/manipulation-linear-tcp-planning/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Linear TCP plans declare path-constraint metadata +Successful linear TCP plans SHALL carry path-constraint metadata that declares the TCP-line constraint downstream post-processing must preserve. + +#### Scenario: Absolute linear TCP plan records constraint metadata +- **WHEN** `plan_linear_to_pose_targets(...)` succeeds for a linear TCP path +- **THEN** the stored `GeneratedPlan` MUST include path-constraint metadata describing the constrained TCP frame, start pose, target pose, and Cartesian deviation tolerances + +#### Scenario: Relative linear TCP plan records resolved absolute constraint metadata +- **WHEN** `plan_linear_relative_to_pose_targets(...)` succeeds for a linear TCP path +- **THEN** the stored `GeneratedPlan` MUST include path-constraint metadata describing the resolved absolute start-to-target TCP segment and Cartesian deviation tolerances + +#### Scenario: Standard and free plans do not claim linear TCP constraints +- **WHEN** standard pose planning or free Cartesian planning succeeds +- **THEN** the stored `GeneratedPlan` MUST NOT declare linear TCP path constraints unless that planner explicitly validated those constraints + +### Requirement: Linear TCP post-processing preserves line tolerance +Post-processing of a linear TCP plan SHALL preserve the declared straight-line TCP constraint within configured Cartesian tolerance. + +#### Scenario: Smoothed linear TCP path remains within tolerance +- **WHEN** trajectory post-processing accepts a smoothed path for a linear TCP plan +- **THEN** the accepted path MUST keep the constrained TCP within the declared translational and rotational tolerances of the start-to-target Cartesian segment + +#### Scenario: Smoothed linear TCP path exceeds tolerance +- **WHEN** a smoothed candidate for a linear TCP plan exceeds declared Cartesian tolerance +- **THEN** the candidate MUST be rejected and non-blocking smoothing fallback MUST apply diff --git a/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/manipulation-trajectory-parametrization/spec.md b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/manipulation-trajectory-parametrization/spec.md new file mode 100644 index 0000000000..f1a40d614f --- /dev/null +++ b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/manipulation-trajectory-parametrization/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: RoboPlan trajectory parametrization uses a validated post-processing pipeline +When RoboPlan trajectory parametrization is configured, the system SHALL run geometric path post-processing through explicit internal stages before TOPP-RA retiming. + +#### Scenario: RoboPlan post-processing runs before TOPP-RA +- **WHEN** a successful `GeneratedPlan` is parametrized with the RoboPlan backend +- **THEN** the backend MUST interpret the input path, optionally preprocess the geometric path, validate any refined path, and only then pass the accepted path to RoboPlan TOPP-RA + +#### Scenario: Pipeline stages remain internally separable +- **WHEN** RoboPlan post-processing is implemented under the trajectory parametrization facade +- **THEN** input interpretation, preprocessing, validation, TOPP-RA retiming, and fallback handling MUST be represented as separable internal responsibilities + +### Requirement: RoboPlan smoothing is conservative and enabled by default +RoboPlan trajectory parametrization SHALL enable conservative geometric smoothing by default for eligible paths. + +#### Scenario: Default smoothing is enabled +- **WHEN** `backend="roboplan"` is configured and no smoothing override is provided +- **THEN** the RoboPlan backend MUST attempt conservative smoothing for eligible generated plans before TOPP-RA retiming + +#### Scenario: Smoothing can be disabled +- **WHEN** configuration disables RoboPlan smoothing +- **THEN** RoboPlan trajectory parametrization MUST parametrize the original geometric path without running smoothing preprocessing + +#### Scenario: Smoothing skips ineligible short paths +- **WHEN** a generated plan has fewer waypoints than the configured smoothing minimum +- **THEN** RoboPlan trajectory parametrization MUST skip smoothing and parametrize the original geometric path + +### Requirement: Smoothing validation preserves geometric correctness +The system SHALL accept a smoothed or simplified path only after validation confirms it preserves the source plan's applicable constraints. + +#### Scenario: Generic validation succeeds +- **WHEN** a path without explicit path-constraint metadata is smoothed +- **THEN** validation MUST confirm endpoint preservation, selected-joint compatibility, joint limits, collision acceptability, and configured maximum joint-space deviation before the smoothed path is used + +#### Scenario: Path-constraint metadata is enforced +- **WHEN** a generated plan includes path-constraint metadata +- **THEN** validation MUST enforce those declared constraints before accepting the smoothed path + +#### Scenario: Invalid smoothing candidate is rejected +- **WHEN** a smoothing candidate violates validation +- **THEN** the backend MUST NOT pass that candidate to TOPP-RA + +### Requirement: RoboPlan smoothing failure is non-blocking +RoboPlan smoothing failures SHALL fall back to the original geometric path rather than failing trajectory parametrization by themselves. + +#### Scenario: Smoothing validation fails +- **WHEN** every smoothing attempt fails validation +- **THEN** RoboPlan trajectory parametrization MUST parametrize the original geometric path +- **AND** smoothing failure alone MUST NOT produce a failed `GeneratedTrajectory` + +#### Scenario: Conservative retry preserves more waypoints +- **WHEN** an aggressive smoothing attempt fails validation +- **THEN** the backend MUST retry with a more conservative candidate when additional smoothing attempts are configured + +#### Scenario: Original parametrization can still fail +- **WHEN** smoothing falls back to the original path and RoboPlan TOPP-RA cannot parametrize the original path +- **THEN** trajectory parametrization MUST report the TOPP-RA failure through `GeneratedTrajectory.status` and message + +### Requirement: Path-constraint metadata is preserved through generated plans +The system SHALL allow geometric generated plans to carry optional path-constraint metadata for downstream trajectory post-processing. + +#### Scenario: GeneratedPlan carries optional metadata +- **WHEN** a `GeneratedPlan` is constructed without path-constraint metadata +- **THEN** the plan MUST remain valid and behave as an unconstrained geometric path for post-processing purposes + +#### Scenario: Planning metadata reaches parametrization +- **WHEN** a successful planning result contains path-constraint metadata and `ManipulationModule` stores it as a `GeneratedPlan` +- **THEN** the resulting `GeneratedPlan` MUST preserve that metadata for trajectory parametrization diff --git a/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/roboplan-cartesian-planning/spec.md b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/roboplan-cartesian-planning/spec.md new file mode 100644 index 0000000000..e4c045c768 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/specs/roboplan-cartesian-planning/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: RoboPlan linear Cartesian planning returns constraint metadata +RoboPlan linear Cartesian planning SHALL return enough path-constraint metadata for downstream trajectory post-processing to validate straight-line TCP preservation. + +#### Scenario: Linear Cartesian result includes TCP segment metadata +- **WHEN** RoboPlan-backed Cartesian planning succeeds with `path_mode="linear"` +- **THEN** the returned planning result MUST include metadata for the constrained planning group, constrained TCP frame, start TCP pose, target TCP pose, and Cartesian deviation tolerances + +#### Scenario: Linear Cartesian metadata matches validated path +- **WHEN** RoboPlanWorld validates a linear Cartesian path before returning success +- **THEN** the returned path-constraint metadata MUST describe the same start and target TCP segment that was validated + +#### Scenario: Free Cartesian result omits linear constraint metadata +- **WHEN** RoboPlan-backed Cartesian planning succeeds with `path_mode="free"` +- **THEN** the returned planning result MUST NOT include linear TCP path-constraint metadata + +### Requirement: RoboPlan post-processing can validate linear TCP constraints +RoboPlanWorld SHALL provide the backend operations needed for trajectory post-processing to validate linear TCP constraints against candidate geometric paths. + +#### Scenario: Candidate path is checked against TCP line +- **WHEN** RoboPlan trajectory post-processing validates a candidate path with linear TCP constraint metadata +- **THEN** RoboPlanWorld MUST evaluate the constrained TCP poses along the candidate path and compare them with the declared Cartesian line tolerance + +#### Scenario: Candidate path cannot be validated +- **WHEN** RoboPlanWorld cannot evaluate the constrained TCP poses or constraint metadata is inconsistent with the selected planning groups +- **THEN** the candidate path MUST be rejected and non-blocking smoothing fallback MUST apply diff --git a/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/tasks.md b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/tasks.md new file mode 100644 index 0000000000..761a966992 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-roboplan-trajectory-postprocessing-pipeline/tasks.md @@ -0,0 +1,36 @@ +## 1. Path Constraint Metadata + +- [x] 1.1 Add typed optional path-constraint metadata models for geometric plans, including a minimal linear TCP constraint model with constrained group/frame, start pose, target pose, and Cartesian tolerances. +- [x] 1.2 Add optional path-constraint metadata fields to `PlanningResult` and `GeneratedPlan` with safe `None` defaults. +- [x] 1.3 Preserve path-constraint metadata when `ManipulationModule` converts successful `PlanningResult` values into stored `GeneratedPlan` artifacts. +- [x] 1.4 Add unit tests proving existing no-metadata plans remain valid and metadata is preserved into parametrization. + +## 2. Linear TCP Metadata Propagation + +- [x] 2.1 Attach linear TCP constraint metadata to successful RoboPlan `path_mode="linear"` Cartesian planning results. +- [x] 2.2 Ensure RoboPlan `path_mode="free"` and standard joint/pose planning results do not claim linear TCP constraints unless explicitly validated. +- [x] 2.3 Add tests for absolute and relative linear TCP planning metadata, including resolved start-to-target segment data. +- [x] 2.4 Add tests that free Cartesian and standard plans leave path-constraint metadata unset. + +## 3. RoboPlan Post-Processing Pipeline Structure + +- [x] 3.1 Add RoboPlan trajectory-parametrization smoothing configuration with conservative defaults, disable switch, minimum waypoint threshold, retry count, and deviation tolerances. +- [x] 3.2 Refactor RoboPlan parametrization internals into separable pipeline responsibilities for input interpretation, preprocessing, validation, TOPP-RA retiming, and fallback handling. +- [x] 3.3 Implement the first preprocessing stage as adaptive waypoint simplification that preserves original endpoints and selects only original waypoints. +- [x] 3.4 Implement adaptive-conservative retry behavior that preserves more original waypoints after validation failure. + +## 4. Validation and Fallback + +- [x] 4.1 Implement generic candidate validation for selected-joint compatibility, endpoints, joint limits, collision acceptability, and configured joint-space deviation. +- [x] 4.2 Implement linear TCP candidate validation that evaluates constrained TCP poses and rejects candidates outside declared translational or rotational tolerance. +- [x] 4.3 Ensure rejected smoothing candidates are never passed to RoboPlan TOPP-RA. +- [x] 4.4 Ensure smoothing/preprocessing failures fall back to parametrizing the original geometric path and do not fail `GeneratedTrajectory` by themselves. +- [x] 4.5 Preserve existing RoboPlan TOPP-RA failure reporting when fallback to the original path still cannot be parametrized. + +## 5. Tests and Documentation + +- [x] 5.1 Add RoboPlan parametrization tests for smoothing enabled by default, smoothing disabled, short-path skip, accepted smoothing, adaptive retry, and fallback to original path. +- [x] 5.2 Add linear TCP tests proving accepted smoothing preserves declared Cartesian line tolerance and violating candidates trigger fallback. +- [x] 5.3 Update relevant manipulation capability docs or examples to mention conservative default RoboPlan smoothing and non-blocking fallback. +- [x] 5.4 Run focused tests for RoboPlan world, manipulation module, linear TCP planning, Viser preview/execution behavior, and trajectory parametrization. +- [x] 5.5 Run formatting and lint checks on modified Python files and OpenSpec validation/status checks for this change. diff --git a/openspec/specs/manipulation-linear-tcp-planning/spec.md b/openspec/specs/manipulation-linear-tcp-planning/spec.md index 0134d09b5e..419f03ad46 100644 --- a/openspec/specs/manipulation-linear-tcp-planning/spec.md +++ b/openspec/specs/manipulation-linear-tcp-planning/spec.md @@ -79,3 +79,29 @@ The Viser panel SHALL track the recipe used to create the current fresh plan sep - **WHEN** Viser displays plan status - **THEN** it SHALL expose the recipe used by the current plan - **AND** it SHALL not imply that toggling the next-plan checkbox changed the current plan recipe + +### Requirement: Linear TCP plans declare path-constraint metadata +Successful linear TCP plans SHALL carry path-constraint metadata that declares the TCP-line constraint downstream post-processing must preserve. + +#### Scenario: Absolute linear TCP plan records constraint metadata +- **WHEN** `plan_linear_to_pose_targets(...)` succeeds for a linear TCP path +- **THEN** the stored `GeneratedPlan` MUST include path-constraint metadata describing the constrained TCP frame, start pose, target pose, and Cartesian deviation tolerances + +#### Scenario: Relative linear TCP plan records resolved absolute constraint metadata +- **WHEN** `plan_linear_relative_to_pose_targets(...)` succeeds for a linear TCP path +- **THEN** the stored `GeneratedPlan` MUST include path-constraint metadata describing the resolved absolute start-to-target TCP segment and Cartesian deviation tolerances + +#### Scenario: Standard and free plans do not claim linear TCP constraints +- **WHEN** standard pose planning or free Cartesian planning succeeds +- **THEN** the stored `GeneratedPlan` MUST NOT declare linear TCP path constraints unless that planner explicitly validated those constraints + +### Requirement: Linear TCP post-processing preserves line tolerance +Post-processing of a linear TCP plan SHALL preserve the declared straight-line TCP constraint within configured Cartesian tolerance. + +#### Scenario: Smoothed linear TCP path remains within tolerance +- **WHEN** trajectory post-processing accepts a smoothed path for a linear TCP plan +- **THEN** the accepted path MUST keep the constrained TCP within the declared translational and rotational tolerances of the start-to-target Cartesian segment + +#### Scenario: Smoothed linear TCP path exceeds tolerance +- **WHEN** a smoothed candidate for a linear TCP plan exceeds declared Cartesian tolerance +- **THEN** the candidate MUST be rejected and non-blocking smoothing fallback MUST apply diff --git a/openspec/specs/manipulation-trajectory-parametrization/spec.md b/openspec/specs/manipulation-trajectory-parametrization/spec.md index e20dc9d338..84b8c855f7 100644 --- a/openspec/specs/manipulation-trajectory-parametrization/spec.md +++ b/openspec/specs/manipulation-trajectory-parametrization/spec.md @@ -126,3 +126,71 @@ The system SHALL distinguish geometric planning status, trajectory parametrizati #### Scenario: Dispatch fails after parametrization succeeds - **WHEN** trajectory parametrization succeeds but coordinator task dispatch fails - **THEN** `GeneratedTrajectory.status` MUST remain successful and dispatch or execution status MUST report the dispatch failure + +### Requirement: RoboPlan trajectory parametrization uses a validated post-processing pipeline +When RoboPlan trajectory parametrization is configured, the system SHALL run geometric path post-processing through explicit internal stages before TOPP-RA retiming. + +#### Scenario: RoboPlan post-processing runs before TOPP-RA +- **WHEN** a successful `GeneratedPlan` is parametrized with the RoboPlan backend +- **THEN** the backend MUST interpret the input path, optionally preprocess the geometric path, validate any refined path, and only then pass the accepted path to RoboPlan TOPP-RA + +#### Scenario: Pipeline stages remain internally separable +- **WHEN** RoboPlan post-processing is implemented under the trajectory parametrization facade +- **THEN** input interpretation, preprocessing, validation, TOPP-RA retiming, and fallback handling MUST be represented as separable internal responsibilities + +### Requirement: RoboPlan smoothing is conservative and enabled by default +RoboPlan trajectory parametrization SHALL enable conservative geometric smoothing by default for eligible paths. + +#### Scenario: Default smoothing is enabled +- **WHEN** `backend="roboplan"` is configured and no smoothing override is provided +- **THEN** the RoboPlan backend MUST attempt conservative smoothing for eligible generated plans before TOPP-RA retiming + +#### Scenario: Smoothing can be disabled +- **WHEN** configuration disables RoboPlan smoothing +- **THEN** RoboPlan trajectory parametrization MUST parametrize the original geometric path without running smoothing preprocessing + +#### Scenario: Smoothing skips ineligible short paths +- **WHEN** a generated plan has fewer waypoints than the configured smoothing minimum +- **THEN** RoboPlan trajectory parametrization MUST skip smoothing and parametrize the original geometric path + +### Requirement: Smoothing validation preserves geometric correctness +The system SHALL accept a smoothed or simplified path only after validation confirms it preserves the source plan's applicable constraints. + +#### Scenario: Generic validation succeeds +- **WHEN** a path without explicit path-constraint metadata is smoothed +- **THEN** validation MUST confirm endpoint preservation, selected-joint compatibility, joint limits, collision acceptability, and configured maximum joint-space deviation before the smoothed path is used + +#### Scenario: Path-constraint metadata is enforced +- **WHEN** a generated plan includes path-constraint metadata +- **THEN** validation MUST enforce those declared constraints before accepting the smoothed path + +#### Scenario: Invalid smoothing candidate is rejected +- **WHEN** a smoothing candidate violates validation +- **THEN** the backend MUST NOT pass that candidate to TOPP-RA + +### Requirement: RoboPlan smoothing failure is non-blocking +RoboPlan smoothing failures SHALL fall back to the original geometric path rather than failing trajectory parametrization by themselves. + +#### Scenario: Smoothing validation fails +- **WHEN** every smoothing attempt fails validation +- **THEN** RoboPlan trajectory parametrization MUST parametrize the original geometric path +- **AND** smoothing failure alone MUST NOT produce a failed `GeneratedTrajectory` + +#### Scenario: Conservative retry preserves more waypoints +- **WHEN** an aggressive smoothing attempt fails validation +- **THEN** the backend MUST retry with a more conservative candidate when additional smoothing attempts are configured + +#### Scenario: Original parametrization can still fail +- **WHEN** smoothing falls back to the original path and RoboPlan TOPP-RA cannot parametrize the original path +- **THEN** trajectory parametrization MUST report the TOPP-RA failure through `GeneratedTrajectory.status` and message + +### Requirement: Path-constraint metadata is preserved through generated plans +The system SHALL allow geometric generated plans to carry optional path-constraint metadata for downstream trajectory post-processing. + +#### Scenario: GeneratedPlan carries optional metadata +- **WHEN** a `GeneratedPlan` is constructed without path-constraint metadata +- **THEN** the plan MUST remain valid and behave as an unconstrained geometric path for post-processing purposes + +#### Scenario: Planning metadata reaches parametrization +- **WHEN** a successful planning result contains path-constraint metadata and `ManipulationModule` stores it as a `GeneratedPlan` +- **THEN** the resulting `GeneratedPlan` MUST preserve that metadata for trajectory parametrization diff --git a/openspec/specs/roboplan-cartesian-planning/spec.md b/openspec/specs/roboplan-cartesian-planning/spec.md index a535d5cd25..216898ecf2 100644 --- a/openspec/specs/roboplan-cartesian-planning/spec.md +++ b/openspec/specs/roboplan-cartesian-planning/spec.md @@ -143,3 +143,29 @@ Planners that do not support Cartesian planning SHALL return `PlanningStatus.UNS #### Scenario: RoboPlan free mode uses terminal IK then joint planning - **WHEN** RoboPlan-backed free Cartesian planning is requested - **THEN** `RoboPlanWorld` MAY solve a final joint goal satisfying all Cartesian targets, run RoboPlan RRT from `start` to that goal, and verify final TCP poses before returning success + +### Requirement: RoboPlan linear Cartesian planning returns constraint metadata +RoboPlan linear Cartesian planning SHALL return enough path-constraint metadata for downstream trajectory post-processing to validate straight-line TCP preservation. + +#### Scenario: Linear Cartesian result includes TCP segment metadata +- **WHEN** RoboPlan-backed Cartesian planning succeeds with `path_mode="linear"` +- **THEN** the returned planning result MUST include metadata for the constrained planning group, constrained TCP frame, start TCP pose, target TCP pose, and Cartesian deviation tolerances + +#### Scenario: Linear Cartesian metadata matches validated path +- **WHEN** RoboPlanWorld validates a linear Cartesian path before returning success +- **THEN** the returned path-constraint metadata MUST describe the same start and target TCP segment that was validated + +#### Scenario: Free Cartesian result omits linear constraint metadata +- **WHEN** RoboPlan-backed Cartesian planning succeeds with `path_mode="free"` +- **THEN** the returned planning result MUST NOT include linear TCP path-constraint metadata + +### Requirement: RoboPlan post-processing can validate linear TCP constraints +RoboPlanWorld SHALL provide the backend operations needed for trajectory post-processing to validate linear TCP constraints against candidate geometric paths. + +#### Scenario: Candidate path is checked against TCP line +- **WHEN** RoboPlan trajectory post-processing validates a candidate path with linear TCP constraint metadata +- **THEN** RoboPlanWorld MUST evaluate the constrained TCP poses along the candidate path and compare them with the declared Cartesian line tolerance + +#### Scenario: Candidate path cannot be validated +- **WHEN** RoboPlanWorld cannot evaluate the constrained TCP poses or constraint metadata is inconsistent with the selected planning groups +- **THEN** the candidate path MUST be rejected and non-blocking smoothing fallback MUST apply From a75da887dbe6ad7c22891e139aa1e794f37b14d9 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 29 Jun 2026 15:21:23 -0700 Subject: [PATCH 100/110] spec: refactor --- .../spec.md | 0 .../spec.md | 0 .../spec.md | 0 .../spec.md | 0 .../spec.md | 5 ++++- .../spec.md | 0 .../spec.md | 5 ++++- .../spec.md | 0 .../spec.md | 0 .../spec.md | 0 .../{runtime-sidecar-protocol => runtime-protocol}/spec.md | 0 .../spec.md | 0 .../spec.md | 0 13 files changed, 8 insertions(+), 2 deletions(-) rename openspec/specs/{benchmark-prelaunch-orchestration => benchmark-prelaunch}/spec.md (100%) rename openspec/specs/{agentic-manipulation-primitives => manipulation-agentic-primitives}/spec.md (100%) rename openspec/specs/{manipulation-linear-tcp-planning => manipulation-linear-tcp}/spec.md (100%) rename openspec/specs/{roboplan-cartesian-planning => manipulation-roboplan-cartesian}/spec.md (100%) rename openspec/specs/{roboplan-composite-multi-robot-planning => manipulation-roboplan-composite}/spec.md (98%) rename openspec/specs/{roboplan-oink-kinematics => manipulation-roboplan-kinematics}/spec.md (100%) rename openspec/specs/{roboplan-planning-group-native-order => manipulation-roboplan-native-order}/spec.md (97%) rename openspec/specs/{manipulation-next-plan-speed-control => manipulation-speed-control}/spec.md (100%) rename openspec/specs/{manipulation-trajectory-parametrization => manipulation-trajectory}/spec.md (100%) rename openspec/specs/{libero-pro-runtime-sidecar => runtime-libero-pro-sidecar}/spec.md (100%) rename openspec/specs/{runtime-sidecar-protocol => runtime-protocol}/spec.md (100%) rename openspec/specs/{robosuite-runtime-sidecar => runtime-robosuite-sidecar}/spec.md (100%) rename openspec/specs/{scripted-runtime-demos => runtime-scripted-demos}/spec.md (100%) diff --git a/openspec/specs/benchmark-prelaunch-orchestration/spec.md b/openspec/specs/benchmark-prelaunch/spec.md similarity index 100% rename from openspec/specs/benchmark-prelaunch-orchestration/spec.md rename to openspec/specs/benchmark-prelaunch/spec.md diff --git a/openspec/specs/agentic-manipulation-primitives/spec.md b/openspec/specs/manipulation-agentic-primitives/spec.md similarity index 100% rename from openspec/specs/agentic-manipulation-primitives/spec.md rename to openspec/specs/manipulation-agentic-primitives/spec.md diff --git a/openspec/specs/manipulation-linear-tcp-planning/spec.md b/openspec/specs/manipulation-linear-tcp/spec.md similarity index 100% rename from openspec/specs/manipulation-linear-tcp-planning/spec.md rename to openspec/specs/manipulation-linear-tcp/spec.md diff --git a/openspec/specs/roboplan-cartesian-planning/spec.md b/openspec/specs/manipulation-roboplan-cartesian/spec.md similarity index 100% rename from openspec/specs/roboplan-cartesian-planning/spec.md rename to openspec/specs/manipulation-roboplan-cartesian/spec.md diff --git a/openspec/specs/roboplan-composite-multi-robot-planning/spec.md b/openspec/specs/manipulation-roboplan-composite/spec.md similarity index 98% rename from openspec/specs/roboplan-composite-multi-robot-planning/spec.md rename to openspec/specs/manipulation-roboplan-composite/spec.md index f0d475b328..6028749794 100644 --- a/openspec/specs/roboplan-composite-multi-robot-planning/spec.md +++ b/openspec/specs/manipulation-roboplan-composite/spec.md @@ -1,4 +1,7 @@ -## ADDED Requirements +## Purpose +Define RoboPlan-backed composite multi-robot planning behavior as part of the manipulation backend surface. + +## Requirements ### Requirement: Multi-robot RoboPlan finalization `RoboPlanWorld` SHALL support registering multiple robot models before constructing the RoboPlan `Scene`, and SHALL construct one Composite RoboPlan model when two or more robots are registered. diff --git a/openspec/specs/roboplan-oink-kinematics/spec.md b/openspec/specs/manipulation-roboplan-kinematics/spec.md similarity index 100% rename from openspec/specs/roboplan-oink-kinematics/spec.md rename to openspec/specs/manipulation-roboplan-kinematics/spec.md diff --git a/openspec/specs/roboplan-planning-group-native-order/spec.md b/openspec/specs/manipulation-roboplan-native-order/spec.md similarity index 97% rename from openspec/specs/roboplan-planning-group-native-order/spec.md rename to openspec/specs/manipulation-roboplan-native-order/spec.md index 27504464af..8a8766ba87 100644 --- a/openspec/specs/roboplan-planning-group-native-order/spec.md +++ b/openspec/specs/manipulation-roboplan-native-order/spec.md @@ -1,4 +1,7 @@ -## ADDED Requirements +## Purpose +Define RoboPlan native joint-order and group adapter contracts as part of the manipulation backend surface. + +## Requirements ### Requirement: RoboPlan registers configured planning groups `RoboPlanWorld` SHALL register planning groups from `RobotModelConfig.planning_groups` using the existing public planning-group registry model. diff --git a/openspec/specs/manipulation-next-plan-speed-control/spec.md b/openspec/specs/manipulation-speed-control/spec.md similarity index 100% rename from openspec/specs/manipulation-next-plan-speed-control/spec.md rename to openspec/specs/manipulation-speed-control/spec.md diff --git a/openspec/specs/manipulation-trajectory-parametrization/spec.md b/openspec/specs/manipulation-trajectory/spec.md similarity index 100% rename from openspec/specs/manipulation-trajectory-parametrization/spec.md rename to openspec/specs/manipulation-trajectory/spec.md diff --git a/openspec/specs/libero-pro-runtime-sidecar/spec.md b/openspec/specs/runtime-libero-pro-sidecar/spec.md similarity index 100% rename from openspec/specs/libero-pro-runtime-sidecar/spec.md rename to openspec/specs/runtime-libero-pro-sidecar/spec.md diff --git a/openspec/specs/runtime-sidecar-protocol/spec.md b/openspec/specs/runtime-protocol/spec.md similarity index 100% rename from openspec/specs/runtime-sidecar-protocol/spec.md rename to openspec/specs/runtime-protocol/spec.md diff --git a/openspec/specs/robosuite-runtime-sidecar/spec.md b/openspec/specs/runtime-robosuite-sidecar/spec.md similarity index 100% rename from openspec/specs/robosuite-runtime-sidecar/spec.md rename to openspec/specs/runtime-robosuite-sidecar/spec.md diff --git a/openspec/specs/scripted-runtime-demos/spec.md b/openspec/specs/runtime-scripted-demos/spec.md similarity index 100% rename from openspec/specs/scripted-runtime-demos/spec.md rename to openspec/specs/runtime-scripted-demos/spec.md From 67a0038e109f209038a5d6261aac9768a9399ce8 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 29 Jun 2026 19:54:27 -0700 Subject: [PATCH 101/110] feat: add GPD MuJoCo grasp demo --- CONTEXT.md | 8 + dimos/core/test_gpd_grasp_demo.py | 472 ++ dimos/manipulation/grasping/grasping.py | 4 +- .../pointcloud_grasp_demo_controller.py | 144 + .../grasping/test_grasping_module.py | 92 + dimos/robot/all_blueprints.py | 3 + .../xarm/blueprints/simulation.py | 34 + .../blueprints/test_gpd_mujoco_grasp_demo.py | 213 + docs/usage/gpd_mujoco_grasp_demo.md | 26 + .../.openspec.yaml | 2 + .../design.md | 63 + .../proposal.md | 30 + .../specs/gpd-grasp-detection/spec.md | 57 + .../specs/venv-module-packaging/spec.md | 16 + .../specs/venv-module-placement/spec.md | 16 + .../tasks.md | 33 + openspec/specs/gpd-grasp-detection/spec.md | 61 + openspec/specs/venv-module-packaging/spec.md | 15 + openspec/specs/venv-module-placement/spec.md | 15 + packages/dimos-gpd-grasp-demo/pixi.lock | 4704 +++++++++++++++++ packages/dimos-gpd-grasp-demo/pixi.toml | 14 + packages/dimos-gpd-grasp-demo/pyproject.toml | 19 + .../src/dimos_gpd_grasp_demo/__init__.py | 25 + .../src/dimos_gpd_grasp_demo/blueprint.py | 65 + .../gpd_grasp_gen_module.py | 201 + packages/dimos-gpd-grasp-demo/uv.lock | 4227 +++++++++++++++ pyproject.toml | 10 +- 27 files changed, 10565 insertions(+), 4 deletions(-) create mode 100644 dimos/core/test_gpd_grasp_demo.py create mode 100644 dimos/manipulation/grasping/pointcloud_grasp_demo_controller.py create mode 100644 dimos/robot/manipulators/xarm/blueprints/test_gpd_mujoco_grasp_demo.py create mode 100644 docs/usage/gpd_mujoco_grasp_demo.md create mode 100644 openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/design.md create mode 100644 openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/proposal.md create mode 100644 openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/gpd-grasp-detection/spec.md create mode 100644 openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/venv-module-packaging/spec.md create mode 100644 openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/venv-module-placement/spec.md create mode 100644 openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/tasks.md create mode 100644 openspec/specs/gpd-grasp-detection/spec.md create mode 100644 packages/dimos-gpd-grasp-demo/pixi.lock create mode 100644 packages/dimos-gpd-grasp-demo/pixi.toml create mode 100644 packages/dimos-gpd-grasp-demo/pyproject.toml create mode 100644 packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/__init__.py create mode 100644 packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/blueprint.py create mode 100644 packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/gpd_grasp_gen_module.py create mode 100644 packages/dimos-gpd-grasp-demo/uv.lock diff --git a/CONTEXT.md b/CONTEXT.md index 7c24d90ab4..ccfd5335ab 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -160,6 +160,14 @@ _Avoid_: censored TSDF, object-only scene A world-frame axis-aligned bounding region used as a rough attention area for a Grasp target before grasp generation. _Avoid_: perfect object geometry, grasp geometry +**Grasp candidate**: +A proposed end-effector grasp pose for a Grasp target, optionally carrying ranking metadata such as score, width, or approach information. +_Avoid_: executed grasp, final pick action, object pose + +**Pointcloud grasp generator**: +A grasp-generation component that consumes point cloud observations of a Grasp target, optionally with scene context, and proposes Grasp candidates. +_Avoid_: TSDF grasp generator, robot execution controller, perception registration module + **SHM runtime data plane**: A local shared-memory command/state channel between a ControlCoordinator-facing hardware adapter and a DimOS simulator client module, used when high-rate motor control must cross local process boundaries without RPC. _Avoid_: remote sidecar protocol, public simulator API, benchmark control plane, module object sharing diff --git a/dimos/core/test_gpd_grasp_demo.py b/dimos/core/test_gpd_grasp_demo.py new file mode 100644 index 0000000000..3b27113cd4 --- /dev/null +++ b/dimos/core/test_gpd_grasp_demo.py @@ -0,0 +1,472 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import sys +from types import ModuleType + +from dimos_gpd_grasp_demo import ( + GPD_RUNTIME_HELP, + GPDGraspGenModule, + NormalizedGraspCandidate, + pointcloud_to_gpd_xyz, +) +import numpy as np +import pytest + +from dimos.core.coordination.blueprints import Blueprint +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.core.runtime_environment import ( + PythonProjectLaunchMaterial, + PythonProjectRuntimeEnvironment, +) +from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 + +_BUILD_WITHOUT_RERUN = {"g": {"viewer": "none", "n_workers": 1}} +_REPO_ROOT = Path(__file__).resolve().parents[2] +_GPD_GRASP_DEMO_PROJECT = _REPO_ROOT / "packages" / "dimos-gpd-grasp-demo" +_GPD_GRASP_DEMO_SRC = _GPD_GRASP_DEMO_PROJECT / "src" +_GPD_COMMIT = "c088d8ae2f7965b067e9a12b3c0dacdbe9da924a" + + +class FakeGpdProjectRuntime(PythonProjectRuntimeEnvironment): + _fake_package_root: Path + + def __init__(self, fake_package_root: Path) -> None: + super().__init__(name="fake-gpd-project", project=_REPO_ROOT) + object.__setattr__(self, "_fake_package_root", fake_package_root) + + def resolve_python_project(self) -> PythonProjectLaunchMaterial: + pythonpath = os.pathsep.join( + [ + str(self._fake_package_root), + str(_GPD_GRASP_DEMO_SRC), + str(_REPO_ROOT), + os.environ.get("PYTHONPATH", ""), + ] + ) + return PythonProjectLaunchMaterial( + argv_prefix=[sys.executable], + cwd=_REPO_ROOT, + env={"PYTHONPATH": pythonpath}, + runtime_name=self.name, + project=self.project, + convention="test-fake-gpd", + prepared_python=Path(sys.executable), + ) + + +@pytest.fixture +def gpd_grasp_demo_import_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.syspath_prepend(str(_GPD_GRASP_DEMO_SRC)) + sys.modules.pop("dimos_gpd_grasp_demo", None) + sys.modules.pop("dimos_gpd_grasp_demo.blueprint", None) + + +def test_gpd_grasp_demo_package_pins_gpd_dependency() -> None: + pyproject = (_GPD_GRASP_DEMO_PROJECT / "pyproject.toml").read_text() + assert 'name = "dimos-gpd-grasp-demo"' in pyproject + assert '"dimos"' in pyproject + assert 'dimos = { path = "../..", editable = true }' in pyproject + assert "gpd @ git+https://github.com/TomCC7/gpd.git" in pyproject + assert _GPD_COMMIT in pyproject + assert "allow-direct-references = true" in pyproject + + +def test_gpd_grasp_demo_package_is_discoverable_without_path_patch() -> None: + sys.modules.pop("dimos_gpd_grasp_demo", None) + sys.modules.pop("dimos_gpd_grasp_demo.blueprint", None) + + spec = importlib.util.find_spec("dimos_gpd_grasp_demo") + + assert spec is not None + assert spec.origin is not None + assert _GPD_GRASP_DEMO_SRC in Path(spec.origin).parents + + +def test_gpd_grasp_demo_pixi_project_has_native_build_dependencies() -> None: + pixi = (_GPD_GRASP_DEMO_PROJECT / "pixi.toml").read_text() + + for package in ( + "cmake", + "compilers", + "eigen", + "opencv", + "pcl", + "pkg-config", + "python", + "uv", + ): + assert f"{package} = " in pixi + + +def test_gpd_grasp_demo_blueprint_imports_without_gpd_core( + gpd_grasp_demo_import_path: None, +) -> None: + sys.modules.pop("gpd", None) + sys.modules.pop("gpd.core", None) + + from dimos_gpd_grasp_demo import ( + GPD_GRASP_DEMO_ENV_NAME, + GPD_GRASP_DEMO_PROJECT, + GpdGraspImportProbe, + gpd_grasp_demo_blueprint, + ) + + assert "gpd.core" not in sys.modules + + blueprint = gpd_grasp_demo_blueprint() + environment = blueprint.runtime_environment_registry.environments[GPD_GRASP_DEMO_ENV_NAME] + + assert isinstance(environment, PythonProjectRuntimeEnvironment) + assert GPD_GRASP_DEMO_PROJECT == _GPD_GRASP_DEMO_PROJECT.resolve() + assert environment.project == _GPD_GRASP_DEMO_PROJECT.resolve() + assert blueprint.runtime_placement_map[GpdGraspImportProbe] == GPD_GRASP_DEMO_ENV_NAME + + +def test_gpd_grasp_gen_blueprint_places_only_generator_in_project_runtime( + gpd_grasp_demo_import_path: None, +) -> None: + from dimos_gpd_grasp_demo import ( + GPD_GRASP_DEMO_ENV_NAME, + GPD_GRASP_DEMO_PROJECT, + GPDGraspGenModule, + gpd_grasp_gen_blueprint, + ) + + blueprint = gpd_grasp_gen_blueprint() + environment = blueprint.runtime_environment_registry.environments[GPD_GRASP_DEMO_ENV_NAME] + + assert isinstance(blueprint, Blueprint) + assert isinstance(environment, PythonProjectRuntimeEnvironment) + assert environment.project == GPD_GRASP_DEMO_PROJECT + assert blueprint.runtime_placement_map == {GPDGraspGenModule: GPD_GRASP_DEMO_ENV_NAME} + + +def test_gpd_grasp_import_rpc_lazily_imports_stubbed_gpd_core( + gpd_grasp_demo_import_path: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from dimos_gpd_grasp_demo import GpdGraspImportProbe + + assert "gpd.core" not in sys.modules + + gpd_module = ModuleType("gpd") + gpd_core_module = ModuleType("gpd.core") + gpd_core_module.__file__ = "/stub/gpd/core.py" + monkeypatch.setitem(sys.modules, "gpd", gpd_module) + monkeypatch.setitem(sys.modules, "gpd.core", gpd_core_module) + + result = GpdGraspImportProbe.import_gpd_core(object()) + + assert result == "gpd import ok: gpd.core (/stub/gpd/core.py)" + + +def test_gpd_grasp_gen_converts_pointcloud_to_backend_xyz() -> None: + pointcloud = PointCloud2.from_numpy( + np.array([[0.0, 0.1, 0.2], [np.nan, 1.0, 1.0], [0.3, 0.4, 0.5]], dtype=np.float32), + frame_id="camera", + timestamp=12.5, + ) + + xyz = pointcloud_to_gpd_xyz(pointcloud) + + assert xyz.dtype == np.float32 + assert xyz.flags.c_contiguous + np.testing.assert_allclose(xyz, [[0.0, 0.1, 0.2], [0.3, 0.4, 0.5]]) + + +def test_gpd_grasp_gen_publishes_candidates_and_pose_array(mocker) -> None: # type: ignore[no-untyped-def] + received_points: list[np.ndarray] = [] + + def backend(points: np.ndarray) -> list[NormalizedGraspCandidate]: + received_points.append(points) + return [ + NormalizedGraspCandidate( + position=(0.1, 0.2, 0.3), + orientation_xyzw=(0.0, 0.0, 0.0, 1.0), + score=0.75, + width=0.04, + ) + ] + + module = GPDGraspGenModule(backend=backend) + publish = mocker.patch.object(module.grasp_candidates, "publish") + pointcloud = PointCloud2.from_numpy( + np.array([[0.0, 0.0, 0.0], [0.1, 0.1, 0.1]], dtype=np.float32), + frame_id="object_frame", + timestamp=42.25, + ) + + try: + poses = module.generate_grasps(pointcloud) + + assert poses is not None + assert poses.header.frame_id == "object_frame" + assert poses.header.timestamp == pytest.approx(42.25) + assert len(poses) == 1 + np.testing.assert_allclose(received_points[0], pointcloud.points_f32()) + published = publish.call_args.args[0] + assert isinstance(published, GraspCandidateArray) + assert published.frame_id == "object_frame" + assert len(published) == 1 + assert published[0].score == pytest.approx(0.75) + assert published[0].jaw_width == pytest.approx(0.04) + assert published.to_rerun() is not None + finally: + module.stop() + + +def test_gpd_grasp_gen_empty_pointcloud_publishes_empty_debug(mocker) -> None: # type: ignore[no-untyped-def] + backend = mocker.Mock(return_value=[]) + module = GPDGraspGenModule(backend=backend) + publish = mocker.patch.object(module.grasp_candidates, "publish") + + try: + result = module.generate_grasps(PointCloud2.from_numpy(np.zeros((0, 3), dtype=np.float32))) + + assert result is None + backend.assert_not_called() + published = publish.call_args.args[0] + assert isinstance(published, GraspCandidateArray) + assert len(published) == 0 + assert published.to_rerun() is not None + finally: + module.stop() + + +def test_gpd_grasp_gen_all_nonfinite_pointcloud_skips_backend(mocker) -> None: # type: ignore[no-untyped-def] + backend = mocker.Mock(return_value=[]) + module = GPDGraspGenModule(backend=backend) + publish = mocker.patch.object(module.grasp_candidates, "publish") + pointcloud = PointCloud2.from_numpy( + np.array([[np.nan, 0.0, 0.0], [np.inf, 1.0, 1.0]], dtype=np.float32), + frame_id="invalid_frame", + ) + + try: + result = module.generate_grasps(pointcloud) + + assert result is None + backend.assert_not_called() + published = publish.call_args.args[0] + assert isinstance(published, GraspCandidateArray) + assert published.frame_id == "invalid_frame" + assert len(published) == 0 + finally: + module.stop() + + +def test_gpd_grasp_gen_valid_cloud_backend_empty_returns_empty_candidates(mocker) -> None: # type: ignore[no-untyped-def] + backend = mocker.Mock(return_value=[]) + module = GPDGraspGenModule(backend=backend) + publish = mocker.patch.object(module.grasp_candidates, "publish") + pointcloud = PointCloud2.from_numpy( + np.array([[0.0, 0.0, 0.0], [0.02, 0.0, 0.0]], dtype=np.float32), + frame_id="valid_empty", + ) + + try: + result = module.generate_grasps(pointcloud) + + assert result is None + backend.assert_called_once() + np.testing.assert_allclose(backend.call_args.args[0], pointcloud.points_f32()) + published = publish.call_args.args[0] + assert isinstance(published, GraspCandidateArray) + assert published.frame_id == "valid_empty" + assert len(published) == 0 + finally: + module.stop() + + +def test_gpd_grasp_gen_rejects_malformed_pointcloud(mocker) -> None: # type: ignore[no-untyped-def] + pointcloud = PointCloud2.from_numpy(np.zeros((1, 3), dtype=np.float32)) + mocker.patch.object(pointcloud, "points_f32", return_value=np.zeros((3,), dtype=np.float32)) + + with pytest.raises(ValueError, match="shape"): + pointcloud_to_gpd_xyz(pointcloud) + + +def test_gpd_grasp_gen_backend_import_failure_has_runtime_help( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sys.modules.pop("gpd", None) + sys.modules.pop("gpd.core", None) + pointcloud = PointCloud2.from_numpy(np.array([[0.0, 0.0, 0.0]], dtype=np.float32)) + module = GPDGraspGenModule() + + try: + with pytest.raises( + RuntimeError, match="GPD grasp detection backend is unavailable" + ) as exc_info: + module.generate_grasps(pointcloud) + + assert GPD_RUNTIME_HELP in str(exc_info.value) + assert "gpd.core" not in sys.modules + finally: + module.stop() + + +def test_gpd_grasp_gen_backend_api_failure_has_runtime_help( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gpd_module = ModuleType("gpd") + gpd_core_module = ModuleType("gpd.core") + monkeypatch.setitem(sys.modules, "gpd", gpd_module) + monkeypatch.setitem(sys.modules, "gpd.core", gpd_core_module) + pointcloud = PointCloud2.from_numpy(np.array([[0.0, 0.0, 0.0]], dtype=np.float32)) + module = GPDGraspGenModule() + + try: + with pytest.raises( + RuntimeError, match="GPD grasp detection backend is unavailable" + ) as exc_info: + module.generate_grasps(pointcloud) + + assert GPD_RUNTIME_HELP in str(exc_info.value) + finally: + module.stop() + + +def test_gpd_grasp_gen_project_runtime_rpc_uses_command_worker( + gpd_grasp_demo_import_path: None, + tmp_path: Path, +) -> None: + fake_gpd = tmp_path / "gpd" + fake_gpd.mkdir() + (fake_gpd / "__init__.py").write_text("") + (fake_gpd / "core.py").write_text( + "class Cloud:\n" + " def __init__(self, points):\n" + " self.points = points\n" + "\n" + "class GraspDetector:\n" + " @classmethod\n" + " def from_preset(cls, preset):\n" + " return cls()\n" + "\n" + " def detect_grasps(self, cloud):\n" + " assert cloud.points.shape[0] > 0\n" + " return [{\n" + " 'position': [0.11, 0.12, 0.13],\n" + " 'orientation': [0.0, 0.0, 0.0, 1.0],\n" + " 'score': 0.9,\n" + " 'width': 0.05,\n" + " }]\n" + ) + + from dimos_gpd_grasp_demo import gpd_grasp_gen_blueprint + + coordinator = ModuleCoordinator.build( + gpd_grasp_gen_blueprint(runtime=FakeGpdProjectRuntime(tmp_path)), + _BUILD_WITHOUT_RERUN.copy(), + ) + try: + detector = coordinator.get_instance(GPDGraspGenModule) + poses = detector.generate_grasps( + PointCloud2.from_numpy( + np.array([[0.0, 0.0, 0.0], [0.02, 0.0, 0.0]], dtype=np.float32), + frame_id="rpc_frame", + timestamp=3.5, + ) + ) + + assert poses is not None + assert poses.header.frame_id == "rpc_frame" + assert poses.header.timestamp == pytest.approx(3.5) + assert len(poses) == 1 + np.testing.assert_allclose(poses[0].position.to_tuple(), (0.11, 0.12, 0.13)) + finally: + coordinator.stop() + + +@pytest.mark.skipif(shutil.which("pixi") is None, reason="Pixi is not installed") +def test_gpd_grasp_demo_pixi_project_runtime_resolves_when_prepared( + gpd_grasp_demo_import_path: None, +) -> None: + prepared_python = _GPD_GRASP_DEMO_PROJECT / ".venv" / "bin" / "python" + if not prepared_python.exists(): + pytest.skip("GPD grasp demo project runtime is not prepared") + + from dimos_gpd_grasp_demo import GPD_GRASP_DEMO_ENV_NAME, gpd_grasp_demo_blueprint + + environment = gpd_grasp_demo_blueprint().runtime_environment_registry.environments[ + GPD_GRASP_DEMO_ENV_NAME + ] + material = environment.resolve_python_project() + + assert material.argv_prefix == ["pixi", "run", "uv", "run", "--no-sync", "python"] + assert material.cwd == _GPD_GRASP_DEMO_PROJECT.resolve() + assert material.prepared_python == prepared_python + + +@pytest.mark.skipif(shutil.which("pixi") is None, reason="Pixi is not installed") +def test_gpd_grasp_demo_pixi_project_runtime_imports_gpd_when_prepared( + gpd_grasp_demo_import_path: None, +) -> None: + prepared_python = _GPD_GRASP_DEMO_PROJECT / ".venv" / "bin" / "python" + if not prepared_python.exists(): + pytest.skip("GPD grasp demo project runtime is not prepared") + + from dimos_gpd_grasp_demo import GpdGraspImportProbe, gpd_grasp_demo_blueprint + + coordinator = ModuleCoordinator.build( + gpd_grasp_demo_blueprint(), + _BUILD_WITHOUT_RERUN.copy(), + ) + try: + probe = coordinator.get_instance(GpdGraspImportProbe) + assert probe.import_gpd_core().startswith("gpd import ok: gpd.core (") + finally: + coordinator.stop() + + +@pytest.mark.skipif(shutil.which("pixi") is None, reason="Pixi is not installed") +def test_gpd_grasp_demo_prepared_runtime_runs_actual_adapter_on_tiny_cloud() -> None: + prepared_python = _GPD_GRASP_DEMO_PROJECT / ".venv" / "bin" / "python" + if not prepared_python.exists(): + pytest.skip("GPD grasp demo project runtime is not prepared") + + script = """ +import numpy as np +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos_gpd_grasp_demo import GPDGraspGenModule + +module = GPDGraspGenModule() +try: + xs, ys = np.meshgrid(np.linspace(-0.03, 0.03, 9), np.linspace(-0.03, 0.03, 9)) + zs = np.zeros_like(xs) + pointcloud = PointCloud2.from_numpy(np.column_stack([xs.ravel(), ys.ravel(), zs.ravel()]).astype(np.float32)) + poses = module.generate_grasps(pointcloud) + if poses is None or len(poses) == 0: + raise AssertionError('real GPD adapter returned no grasps; cannot validate output normalization') + first = poses[0] + first.position.to_tuple() + first.orientation.to_tuple() +finally: + module.stop() +""" + subprocess.run( + [str(prepared_python), "-c", script], + cwd=_GPD_GRASP_DEMO_PROJECT, + check=True, + timeout=60, + ) diff --git a/dimos/manipulation/grasping/grasping.py b/dimos/manipulation/grasping/grasping.py index 555962d738..a000f27620 100644 --- a/dimos/manipulation/grasping/grasping.py +++ b/dimos/manipulation/grasping/grasping.py @@ -97,7 +97,7 @@ def generate_grasps( return msg if result is None or len(result.poses) == 0: - msg = f"No grasps generated for '{object_name}'" + msg = f"Pointcloud grasp generator returned no grasps for '{object_name}'." logger.info(msg) return msg @@ -193,7 +193,7 @@ def _format_grasp_result(self, grasps: PoseArray, object_name: str) -> str: pos = best.position rpy = quaternion_to_euler(best.orientation, degrees=True) return ( - f"Generated {len(grasps.poses)}" + f"Generated {len(grasps.poses)} grasp(s). " f"Best grasp: pos=({pos.x:.4f}, {pos.y:.4f}, {pos.z:.4f}), " f"rpy=({rpy.x:.1f}, {rpy.y:.1f}, {rpy.z:.1f}) degrees" ) diff --git a/dimos/manipulation/grasping/pointcloud_grasp_demo_controller.py b/dimos/manipulation/grasping/pointcloud_grasp_demo_controller.py new file mode 100644 index 0000000000..bba785b6bf --- /dev/null +++ b/dimos/manipulation/grasping/pointcloud_grasp_demo_controller.py @@ -0,0 +1,144 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import threading +import time +from typing import Protocol + +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import Out +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.TargetBounds import TargetBounds +from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject +from dimos.perception.object_scene_registration_spec import ObjectSceneRegistrationSpec +from dimos.spec.utils import Spec +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + + +class PointcloudGraspingSpec(Spec, Protocol): + def generate_grasps( + self, + object_name: str = "object", + object_id: str | None = None, + filter_collisions: bool = True, + ) -> str: ... + + +class PointcloudGraspDemoControllerConfig(ModuleConfig): + target_name: str = "sphere" + detection_timeout_s: float = 45.0 + retry_interval_s: float = 0.5 + pointcloud_settle_s: float = 1.0 + filter_collisions: bool = True + workspace_center: tuple[float, float, float] = (0.45, 0.0, 0.18) + + +class PointcloudGraspDemoController(Module): + """Trigger pointcloud grasp generation for a registered object without robot execution.""" + + config: PointcloudGraspDemoControllerConfig + + grasp_target_bounds: Out[TargetBounds] + + _scene_registration: ObjectSceneRegistrationSpec + _grasping: PointcloudGraspingSpec + + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + + @rpc + def start(self) -> None: + super().start() + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run_demo, + name="PointcloudGraspDemoController", + daemon=True, + ) + self._thread.start() + + @rpc + def stop(self) -> None: + self._stop_event.set() + super().stop() + + def _run_demo(self) -> None: + target_name = self.config.target_name + logger.info("Starting pointcloud GPD grasp demo for '%s'", target_name) + self._scene_registration.set_prompts(text=[target_name]) + target = self._wait_for_target(target_name) + if target is None: + logger.warning("No registered object matched target '%s'", target_name) + self._publish_empty_target_bounds(target_name) + return + + self._publish_target_bounds(target) + self._wait(self.config.pointcloud_settle_s) + if self._stop_event.is_set(): + return + result = self._grasping.generate_grasps( + object_name=target.name, + object_id=target.object_id, + filter_collisions=self.config.filter_collisions, + ) + logger.info("Pointcloud GPD grasp demo result: %s", result) + + def _wait_for_target(self, target_name: str) -> RegisteredObject | None: + deadline = time.time() + self.config.detection_timeout_s + while time.time() < deadline and not self._stop_event.is_set(): + matches = [ + obj + for obj in self._scene_registration.get_registered_objects() + if obj.name.lower() == target_name.lower() + ] + if matches: + return min(matches, key=self._workspace_distance) + self._wait(self.config.retry_interval_s) + return None + + def _workspace_distance(self, obj: RegisteredObject) -> float: + cx, cy, cz = self.config.workspace_center + center = Vector3(cx, cy, cz) + return center.distance(obj.center) + + def _wait(self, duration_s: float) -> None: + self._stop_event.wait(max(0.0, duration_s)) + + def _publish_target_bounds(self, target: RegisteredObject) -> None: + self.grasp_target_bounds.publish( + TargetBounds( + center=target.center, + size=target.size, + frame_id=target.frame_id, + ts=target.ts, + label=f"{target.name}:{target.object_id}", + ) + ) + + def _publish_empty_target_bounds(self, target_name: str) -> None: + self.grasp_target_bounds.publish( + TargetBounds( + center=Vector3(), + size=Vector3(), + frame_id="world", + label=f"no target: {target_name}", + ) + ) diff --git a/dimos/manipulation/grasping/test_grasping_module.py b/dimos/manipulation/grasping/test_grasping_module.py index 9fa06ce05a..93375f236d 100644 --- a/dimos/manipulation/grasping/test_grasping_module.py +++ b/dimos/manipulation/grasping/test_grasping_module.py @@ -14,6 +14,7 @@ from collections.abc import Generator +import numpy as np import pytest from pytest_mock import MockerFixture @@ -24,9 +25,26 @@ from dimos.msgs.grasping_msgs.GraspCandidate import GraspCandidate from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 from dimos.msgs.std_msgs.Header import Header +class FakeGPDGraspGenModule: + def __init__(self, result: object) -> None: + self.result = result + self.calls: list[tuple[PointCloud2, PointCloud2 | None]] = [] + + def generate_grasps( + self, + pointcloud: PointCloud2, + scene_pointcloud: PointCloud2 | None = None, + ) -> object: + self.calls.append((pointcloud, scene_pointcloud)) + if isinstance(self.result, Exception): + raise self.result + return self.result + + @pytest.fixture(autouse=True) def _stop_created_modules(mocker: MockerFixture) -> Generator[None, None, None]: modules: list[GraspingModule] = [] @@ -129,3 +147,77 @@ def test_generate_grasps_pointcloud_flow_unchanged(mocker: MockerFixture) -> Non grasp_gen.generate_grasps.assert_called_once_with(object_pc, scene_pc) assert len(published) == 1 assert "Generated 1" in message + + +def test_generate_grasps_routes_registered_object_pointcloud_through_gpd_detector( + mocker: MockerFixture, +) -> None: + module = GraspingModule() + scene_registration = mocker.Mock() + object_pc = PointCloud2.from_numpy( + np.array([[0.0, 0.0, 0.0], [0.02, 0.0, 0.0]], dtype=np.float32), + frame_id="object", + ) + scene_pc = PointCloud2.from_numpy( + np.array([[1.0, 1.0, 1.0]], dtype=np.float32), + frame_id="scene", + ) + scene_registration.get_object_pointcloud_by_object_id.return_value = object_pc + scene_registration.get_full_scene_pointcloud.return_value = scene_pc + pose_array = _candidate_array().to_pose_array() + gpd_detector = FakeGPDGraspGenModule(pose_array) + mocker.patch.object(module, "_scene_registration", scene_registration, create=True) + mocker.patch.object(module, "_grasp_gen", gpd_detector, create=True) + published = [] + module.grasps.subscribe(published.append) + + message = module.generate_grasps(object_name="mug", object_id="obj-1", filter_collisions=True) + + assert gpd_detector.calls == [(object_pc, scene_pc)] + assert len(published) == 1 + assert published[0] is pose_array + assert "Generated 1" in message + + +def test_generate_grasps_empty_gpd_output_returns_clear_message_and_does_not_publish( + mocker: MockerFixture, +) -> None: + module = GraspingModule() + scene_registration = mocker.Mock() + object_pc = PointCloud2.from_numpy( + np.array([[0.0, 0.0, 0.0]], dtype=np.float32), + frame_id="object", + ) + scene_registration.get_object_pointcloud_by_name.return_value = object_pc + gpd_detector = FakeGPDGraspGenModule(None) + mocker.patch.object(module, "_scene_registration", scene_registration, create=True) + mocker.patch.object(module, "_grasp_gen", gpd_detector, create=True) + publish = mocker.patch.object(module.grasps, "publish") + + message = module.generate_grasps(object_name="mug", filter_collisions=False) + + assert gpd_detector.calls == [(object_pc, None)] + publish.assert_not_called() + assert message == "Pointcloud grasp generator returned no grasps for 'mug'." + + +def test_generate_grasps_failed_gpd_output_returns_clear_message_and_does_not_publish( + mocker: MockerFixture, +) -> None: + module = GraspingModule() + scene_registration = mocker.Mock() + object_pc = PointCloud2.from_numpy( + np.array([[0.0, 0.0, 0.0]], dtype=np.float32), + frame_id="object", + ) + scene_registration.get_object_pointcloud_by_name.return_value = object_pc + gpd_detector = FakeGPDGraspGenModule(RuntimeError("GPD backend unavailable")) + mocker.patch.object(module, "_scene_registration", scene_registration, create=True) + mocker.patch.object(module, "_grasp_gen", gpd_detector, create=True) + publish = mocker.patch.object(module.grasps, "publish") + + message = module.generate_grasps(object_name="mug", filter_collisions=False) + + assert gpd_detector.calls == [(object_pc, None)] + publish.assert_not_called() + assert message == "Grasp generation failed: GPD backend unavailable" diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 91177b482c..3a69bf3163 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -62,6 +62,7 @@ "drone-basic": "dimos.robot.drone.blueprints.basic.drone_basic:drone_basic", "dual-xarm6-planner": "dimos.robot.manipulators.xarm.blueprints.basic:dual_xarm6_planner", "dual-xarm6-planner-coordinator": "dimos.robot.manipulators.xarm.blueprints.basic:dual_xarm6_planner_coordinator", + "gpd-mujoco-grasp-demo": "dimos.robot.manipulators.xarm.blueprints.simulation:gpd_mujoco_grasp_demo", "keyboard-teleop-a750": "dimos.robot.manipulators.a750.blueprints.teleop:keyboard_teleop_a750", "keyboard-teleop-openarm": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm", "keyboard-teleop-openarm-mock": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm_mock", @@ -219,6 +220,7 @@ "phone-teleop-module": "dimos.teleop.phone.phone_teleop_module.PhoneTeleopModule", "pick-and-place-module": "dimos.manipulation.pick_and_place_module.PickAndPlaceModule", "point-lio": "dimos.hardware.sensors.lidar.pointlio.module.PointLio", + "pointcloud-grasp-demo-controller": "dimos.manipulation.grasping.pointcloud_grasp_demo_controller.PointcloudGraspDemoController", "pointlio-recorder": "dimos.hardware.sensors.lidar.pointlio.recorder.PointlioRecorder", "quest-teleop-module": "dimos.teleop.quest.quest_teleop_module.QuestTeleopModule", "ray-tracing-voxel-map": "dimos.mapping.ray_tracing.module.RayTracingVoxelMap", @@ -238,6 +240,7 @@ "spatial-memory": "dimos.perception.spatial_perception.SpatialMemory", "speak-skill": "dimos.agents.skills.speak_skill.SpeakSkill", "tare-planner": "dimos.navigation.cmu_nav.modules.tare_planner.tare_planner.TarePlanner", + "target-grasp-demo-controller": "dimos.manipulation.grasping.target_grasp_demo_controller.TargetGraspDemoController", "teleop-recorder": "dimos.teleop.utils.recorder.TeleopRecorder", "temporal-memory": "dimos.perception.experimental.temporal_memory.temporal_memory.TemporalMemory", "terrain-analysis": "dimos.navigation.cmu_nav.modules.terrain_analysis.terrain_analysis.TerrainAnalysis", diff --git a/dimos/robot/manipulators/xarm/blueprints/simulation.py b/dimos/robot/manipulators/xarm/blueprints/simulation.py index fa246f2905..2c4ac3dec1 100644 --- a/dimos/robot/manipulators/xarm/blueprints/simulation.py +++ b/dimos/robot/manipulators/xarm/blueprints/simulation.py @@ -18,8 +18,14 @@ import math +from dimos_gpd_grasp_demo.blueprint import gpd_grasp_gen_blueprint +from dimos_gpd_grasp_demo.gpd_grasp_gen_module import GPDGraspGenModule + from dimos.core.coordination.blueprints import autoconnect from dimos.manipulation.grasping.grasping import GraspingModule +from dimos.manipulation.grasping.pointcloud_grasp_demo_controller import ( + PointcloudGraspDemoController, +) from dimos.manipulation.grasping.target_grasp_demo_controller import TargetGraspDemoController from dimos.manipulation.grasping.vgn_grasp_gen_module import VGNGraspGenModule from dimos.manipulation.pick_and_place_module import PickAndPlaceModule @@ -120,3 +126,31 @@ (VGNGraspGenModule, "tsdf", "tsdf_surface"), ] ) + +gpd_mujoco_grasp_demo = autoconnect( + MujocoSimModule.blueprint( + address=str(XARM7_SIM_PATH), + headless=False, + dof=7, + camera_name="wrist_camera", + base_frame_id="link7", + enable_depth=True, + enable_color=True, + enable_pointcloud=True, + camera_info_fps=5.0, + initial_joint_positions=XARM7_VGN_OBSERVATION_HOME, + ), + ObjectSceneRegistrationModule.blueprint( + target_frame="world", + min_detections_for_permanent=1, + use_aabb=True, + ), + gpd_grasp_gen_blueprint(), + GraspingModule.blueprint(), + PointcloudGraspDemoController.blueprint(target_name="sphere", filter_collisions=False), + RerunBridgeModule.blueprint(), +).remappings( + [ + (GPDGraspGenModule, "grasp_candidates", "gpd_grasp_candidates"), + ] +) diff --git a/dimos/robot/manipulators/xarm/blueprints/test_gpd_mujoco_grasp_demo.py b/dimos/robot/manipulators/xarm/blueprints/test_gpd_mujoco_grasp_demo.py new file mode 100644 index 0000000000..d5432cd7b7 --- /dev/null +++ b/dimos/robot/manipulators/xarm/blueprints/test_gpd_mujoco_grasp_demo.py @@ -0,0 +1,213 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import os +from pathlib import Path + +from dimos_gpd_grasp_demo.blueprint import GPD_GRASP_DEMO_ENV_NAME, GPD_GRASP_DEMO_PROJECT +from dimos_gpd_grasp_demo.gpd_grasp_gen_module import GPDGraspGenModule +import pytest +from pytest_mock import MockerFixture + +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment +from dimos.manipulation.grasping.grasping import GraspingModule +from dimos.manipulation.grasping.pointcloud_grasp_demo_controller import ( + PointcloudGraspDemoController, +) +from dimos.manipulation.grasping.target_grasp_demo_controller import TargetGraspDemoController +from dimos.manipulation.grasping.vgn_grasp_gen_module import VGNGraspGenModule +from dimos.manipulation.pick_and_place_module import PickAndPlaceModule +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray +from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.perception.object_scene_registration import ObjectSceneRegistrationModule +from dimos.perception.reconstruction import SceneReconstructionModule +from dimos.robot.all_blueprints import all_blueprints +from dimos.robot.manipulators.xarm.blueprints.simulation import ( + gpd_mujoco_grasp_demo, + vgn_mujoco_grasp_demo, + xarm_perception_sim, +) +from dimos.simulation.engines.mujoco_sim_module import MujocoSimModule +from dimos.visualization.rerun.bridge import RerunBridgeModule + + +def test_gpd_mujoco_grasp_demo_blueprint_is_opt_in() -> None: + demo_modules = {atom.module for atom in gpd_mujoco_grasp_demo.blueprints} + perception_modules = {atom.module for atom in xarm_perception_sim.blueprints} + vgn_modules = {atom.module for atom in vgn_mujoco_grasp_demo.blueprints} + + assert demo_modules == { + MujocoSimModule, + ObjectSceneRegistrationModule, + GPDGraspGenModule, + GraspingModule, + PointcloudGraspDemoController, + RerunBridgeModule, + } + assert GPDGraspGenModule not in perception_modules + assert PointcloudGraspDemoController not in perception_modules + assert PickAndPlaceModule not in demo_modules + assert TargetGraspDemoController in vgn_modules + assert VGNGraspGenModule in vgn_modules + assert PointcloudGraspDemoController not in vgn_modules + + +def test_gpd_mujoco_grasp_demo_places_only_generator_in_gpd_runtime() -> None: + placements = dict(gpd_mujoco_grasp_demo.runtime_placement_map) + environment = gpd_mujoco_grasp_demo.runtime_environment_registry.resolve( + GPD_GRASP_DEMO_ENV_NAME + ) + + assert placements == {GPDGraspGenModule: GPD_GRASP_DEMO_ENV_NAME} + assert isinstance(environment, PythonProjectRuntimeEnvironment) + assert environment.project == GPD_GRASP_DEMO_PROJECT + + +def test_gpd_mujoco_grasp_demo_uses_stable_rerun_topics() -> None: + assert ( + gpd_mujoco_grasp_demo.remapping_map[(GPDGraspGenModule, "grasp_candidates")] + == "gpd_grasp_candidates" + ) + assert hasattr(GraspCandidateArray, "to_rerun") + + +def test_gpd_mujoco_grasp_demo_configuration_does_not_change_vgn_or_perception() -> None: + assert SceneReconstructionModule not in { + atom.module for atom in gpd_mujoco_grasp_demo.blueprints + } + assert GPDGraspGenModule not in {atom.module for atom in vgn_mujoco_grasp_demo.blueprints} + + sim_atom = next( + atom for atom in gpd_mujoco_grasp_demo.blueprints if atom.module is MujocoSimModule + ) + controller_atom = next( + atom + for atom in gpd_mujoco_grasp_demo.blueprints + if atom.module is PointcloudGraspDemoController + ) + + assert sim_atom.kwargs["enable_pointcloud"] is True + assert sim_atom.kwargs["enable_depth"] is True + assert controller_atom.kwargs["target_name"] == "sphere" + assert controller_atom.kwargs["filter_collisions"] is False + + +def test_gpd_mujoco_grasp_demo_registry_name_is_stable() -> None: + assert ( + all_blueprints["gpd-mujoco-grasp-demo"] + == "dimos.robot.manipulators.xarm.blueprints.simulation:gpd_mujoco_grasp_demo" + ) + + +class _SceneRegistrationFake: + def __init__(self, target: RegisteredObject) -> None: + self.target = target + self.prompts: list[list[str]] = [] + + def set_prompts(self, text: list[str] | None = None, bboxes: object | None = None) -> None: + del bboxes + self.prompts.append(text or []) + + def get_registered_objects(self) -> list[RegisteredObject]: + return [self.target] + + def get_object_by_object_id(self, object_id: str) -> RegisteredObject | None: + return self.target if object_id == self.target.object_id else None + + def get_object_pointcloud_by_name(self, name: str) -> PointCloud2 | None: + del name + return None + + def get_object_pointcloud_by_object_id(self, object_id: str) -> PointCloud2 | None: + del object_id + return None + + def get_full_scene_pointcloud( + self, + exclude_object_id: str | None = None, + depth_trunc: float = 2.0, + voxel_size: float = 0.01, + ) -> PointCloud2 | None: + del exclude_object_id, depth_trunc, voxel_size + return None + + +class _PointcloudGraspingFake: + def __init__(self) -> None: + self.calls: list[tuple[str, str | None, bool]] = [] + + def generate_grasps( + self, + object_name: str = "object", + object_id: str | None = None, + filter_collisions: bool = True, + ) -> str: + self.calls.append((object_name, object_id, filter_collisions)) + return "generated" + + +def test_pointcloud_demo_controller_calls_pointcloud_generation_only(mocker: MockerFixture) -> None: + target = RegisteredObject( + object_id="obj-1", + name="sphere", + center=Vector3(0.45, 0.0, 0.18), + size=Vector3(0.05, 0.05, 0.05), + ) + scene = _SceneRegistrationFake(target) + grasping = _PointcloudGraspingFake() + controller = PointcloudGraspDemoController( + target_name="sphere", + detection_timeout_s=0.01, + pointcloud_settle_s=0.0, + filter_collisions=False, + ) + controller._scene_registration = scene + controller._grasping = grasping + published = [] + controller.grasp_target_bounds.subscribe(published.append) + forbidden = mocker.Mock(side_effect=AssertionError("execution API must not be called")) + + try: + controller._run_demo() + + forbidden.assert_not_called() + assert scene.prompts == [["sphere"]] + assert grasping.calls == [("sphere", "obj-1", False)] + assert published[0].label == "sphere:obj-1" + finally: + controller.stop() + + +@pytest.mark.skipif( + not Path("packages/dimos-gpd-grasp-demo/.venv/bin/python").exists(), + reason=( + "GPD prepared runtime is not available; run `uv run dimos runtime prepare " + "gpd-mujoco-grasp-demo --runtime dimos-gpd-grasp-demo` first" + ), +) +@pytest.mark.skipif( + os.environ.get("DIMOS_RUN_GPD_MUJOCO_SMOKE") != "1", + reason="Set DIMOS_RUN_GPD_MUJOCO_SMOKE=1 to build the full GPD MuJoCo demo smoke", +) +def test_gpd_mujoco_grasp_demo_prepared_runtime_smoke_gate() -> None: + coordinator = ModuleCoordinator.build( + gpd_mujoco_grasp_demo, + {"g": {"viewer": "none", "n_workers": 1}}, + ) + coordinator.stop() diff --git a/docs/usage/gpd_mujoco_grasp_demo.md b/docs/usage/gpd_mujoco_grasp_demo.md new file mode 100644 index 0000000000..2d11d1a974 --- /dev/null +++ b/docs/usage/gpd_mujoco_grasp_demo.md @@ -0,0 +1,26 @@ +# GPD MuJoCo Grasp Demo + +`gpd-mujoco-grasp-demo` is an opt-in xArm7 MuJoCo demo for pointcloud-based GPD grasp candidate generation. It does not change `xarm-perception-sim` or `vgn-mujoco-grasp-demo`, and it does not execute robot motion, gripper, pick/place, or trajectory APIs. + +Prepare the optional GPD project runtime first: + +```bash +uv run dimos runtime prepare gpd-mujoco-grasp-demo --runtime dimos-gpd-grasp-demo +``` + +Run the demo with MuJoCo simulation and Rerun visualization: + +```bash +uv run dimos run gpd-mujoco-grasp-demo +``` + +The demo composes MuJoCo xArm simulation, existing object registration/pointcloud perception, `GraspingModule`, a placed `GPDGraspGenModule` in the `dimos-gpd-grasp-demo` project runtime, a pointcloud-only trigger controller, and `RerunBridgeModule`. + +Expected Rerun-visible outputs after the registered target is detected and the controller triggers GPD include: + +- `world/grasp_target_bounds` +- `world/gpd_grasp_candidates` + +`GraspingModule` also publishes compatible `PoseArray` results on `grasps` for downstream code, but candidate visualization in Rerun comes from `gpd_grasp_candidates`. + +If no target object is registered before the configured timeout, the controller logs an empty-result message and publishes empty target bounds. If GPD returns no candidates after a target is registered, the controller/module logs an explicit empty-result message and still avoids robot execution. diff --git a/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/.openspec.yaml b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/design.md b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/design.md new file mode 100644 index 0000000000..9c209766c2 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/design.md @@ -0,0 +1,63 @@ +## Context + +DimOS already has two grasp-generation paths: + +- A pointcloud path in `GraspingModule.generate_grasps(...)` that calls `GraspGenSpec.generate_grasps(pointcloud, scene_pointcloud)` and publishes a `PoseArray`. +- A TSDF path where the VGN demo uses `SceneReconstructionModule`, `VGNGraspGenModule`, and `TargetGraspDemoController` to produce target-conditioned grasp candidates. + +GPD is pointcloud-native, so it should not be modeled as another TSDF/VGN generator and it should not own pointcloud production. The first GPD grasp demo should prove that a real xArm MuJoCo perception workflow can route a registered grasp target's pointcloud, produced by existing perception modules, through the existing pointcloud grasp path into a project-runtime GPD worker. The user-facing deliverable must include a concrete command that starts the simulation, runs existing pointcloud/object perception, invokes GPD grasp detection from the resulting pointcloud, and makes the result visible in Rerun. + +The prior GPD worker demo already proves that the pinned GPD binding can be prepared and imported in a Pixi-backed project runtime. This change builds on that runtime capability by adding a grasp-generation Module and a VGN-like workflow demo. + +## Goals / Non-Goals + +**Goals:** + +- Add an import-safe GPD grasp detector Module in a package-local Python project runtime that consumes existing `PointCloud2` inputs. +- Implement the existing `GraspGenSpec` contract so `GraspingModule.generate_grasps(...)` can call GPD without a new user-facing grasp skill API. +- Preserve richer GPD/debug metadata through optional `GraspCandidateArray` or debug streams while returning `PoseArray` for compatibility. +- Add a deterministic sample/synthetic pointcloud test harness for conversion and result handling. +- Add an xArm MuJoCo demo blueprint, similar in spirit to `vgn_mujoco_grasp_demo`, that exercises object registration, `GraspingModule`, and the placed GPD generator. +- Provide a documented end-to-end command that runs the demo with simulation, existing pointcloud perception, GPD grasp detection, and Rerun visualization active. + +**Non-Goals:** + +- No robot execution, pick/place motion, or gripper actuation in the first GPD demo. +- No replacement of VGN or the TSDF grasp path. +- No broad migration of `GraspGenSpec` from `PoseArray` to `GraspCandidateArray`. +- No guarantee that GPD returns a non-empty grasp set for every test scene; empty results must be explicit and observable. + +## Decisions + +### Use the existing pointcloud `GraspGenSpec` + +`GPDGraspGenModule` will implement `GraspGenSpec` and return `PoseArray | None` from `generate_grasps(...)`. This keeps the current `GraspingModule.generate_grasps(...)` skill path working and avoids broad API churn. + +Alternative considered: introduce a new pointcloud candidate spec returning `GraspCandidateArray`. That is cleaner long-term but would require `GraspingModule` changes and more downstream contract decisions. This change may publish candidates for debug, but the required compatibility surface remains `PoseArray`. + +### Keep GPD in a demo package first + +The first grasp generator will live under a new package in `packages/`, not directly under `dimos/manipulation/grasping`. The package owns the GPD/Pixi dependency closure and must be import-safe in the coordinator environment. + +Alternative considered: add `GPDGraspGenModule` directly to core grasping. That may be appropriate later, but a package-local first slice isolates native dependency risk and follows the project-runtime package pattern. + +### Build two validation layers + +The change will include both: + +- A deterministic sample/synthetic pointcloud harness that tests GPD conversion/output handling without MuJoCo or perception timing. +- A headline xArm MuJoCo demo workflow that mirrors the VGN demo pattern and proves the end-to-end registered-object path reaches GPD. + +The sample harness is the debugging floor. The MuJoCo workflow is the user-visible demo, and it must be runnable from a documented command rather than only existing as unit-test wiring. + +### Stop at candidate generation + +The demo produces and visualizes/logs grasp poses or a clear empty-result message. It does not execute a grasp. Current code has an opt-in VGN candidate demo but no proven pointcloud grasp execution path, and `PickAndPlaceModule.generate_grasps()` is not a suitable acceptance target for this slice. + +## Risks / Trade-offs + +- **GPD binding API mismatch** → Encapsulate binding calls in the demo package adapter and test conversion with stubbed and prepared-runtime paths. +- **Pointcloud frame or unit mismatch** → Validate `PointCloud2` to GPD conversion, preserve frame ids in returned `PoseArray`, and document expected input frame behavior. +- **GPD returns no grasps for the default scene** → Treat empty output as a valid explicit result, but keep tests that prove the call path and published outputs are observable. +- **Native dependency friction** → Reuse the Pixi-backed project runtime pattern and keep optional Pixi/GPD integration tests skippable when the runtime is not prepared. +- **Contract loses GPD scoring metadata** → Publish optional debug candidates while preserving the existing `PoseArray` return contract. diff --git a/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/proposal.md b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/proposal.md new file mode 100644 index 0000000000..6005a23903 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/proposal.md @@ -0,0 +1,30 @@ +## Why + +DimOS now has project-runtime workers that can host native Python bindings, but the GPD demo only proves that `gpd.core` can be imported. The grasping stack still lacks a runnable command that starts the xArm MuJoCo simulation, uses existing perception modules to produce pointcloud data, runs GPD grasp detection from that pointcloud, and shows the result in Rerun like the current VGN demo. + +## What Changes + +- Add an import-safe GPD grasp demo package under `packages/` with a pointcloud-consuming grasp detector Module that runs inside a Python project runtime. +- Wire the GPD generator into the existing `GraspGenSpec` pointcloud path so `GraspingModule.generate_grasps(...)` can call it without changing the user-facing skill contract. +- Publish compatible `PoseArray` outputs and preserve richer candidate/debug data where available without requiring a new core grasp API in this slice. +- Add a VGN-like xArm MuJoCo demo blueprint that routes a registered grasp target through object registration, `GraspingModule`, and the GPD project-runtime worker. +- Provide a documented end-to-end demo command that runs simulation, pointcloud/object perception, GPD grasp detection, and Rerun visualization. +- Add deterministic sample/synthetic pointcloud tests that validate conversion and output behavior independently from MuJoCo/perception timing. +- Do not execute robot pick/place motions in this change; the demo stops at candidate generation and visualization/logging. + +## Capabilities + +### New Capabilities +- `gpd-grasp-detection`: GPD grasp detection from pointcloud inputs and demo workflow requirements. + +### Modified Capabilities +- `venv-module-packaging`: Add a GPD grasp demo package requirement that goes beyond import probing and exercises grasp detection from pointcloud inputs. +- `venv-module-placement`: Add a project-runtime placement scenario for a real GPD grasp generator Module in an xArm MuJoCo workflow. + +## Impact + +- New package under `packages/` for the GPD grasp demo Module, blueprint factory, and project-runtime manifests. +- New grasping Module tests for pointcloud conversion, lazy GPD imports, empty-result handling, and published outputs. +- New xArm MuJoCo demo blueprint and blueprint-level tests modeled after the existing VGN demo. +- Documentation for preparing and running the end-to-end GPD grasp detection demo command with Rerun visualization. +- No breaking changes to `GraspingModule`, `GraspGenSpec`, VGN, or existing xArm blueprints. diff --git a/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/gpd-grasp-detection/spec.md b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/gpd-grasp-detection/spec.md new file mode 100644 index 0000000000..d1973200cb --- /dev/null +++ b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/gpd-grasp-detection/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: GPD detector consumes existing pointcloud inputs through the grasp generation contract +The system SHALL provide an import-safe GPD grasp detector Module that consumes existing DimOS `PointCloud2` inputs, implements the existing `GraspGenSpec` contract, and can be called by `GraspingModule.generate_grasps(...)`. + +#### Scenario: GraspingModule calls GPD through GraspGenSpec +- **WHEN** `GraspingModule.generate_grasps(...)` resolves a registered grasp target pointcloud from existing perception modules and a GPD detector is wired as `_grasp_gen` +- **THEN** the module calls `generate_grasps(pointcloud, scene_pointcloud)` on the GPD detector and publishes the returned `PoseArray` through the existing `grasps` output + +#### Scenario: GPD package import is coordinator-safe +- **WHEN** the coordinator imports the GPD grasp generator Module class for blueprint wiring +- **THEN** the import succeeds without importing `gpd.core` or other worker-only native dependencies at module import time + +### Requirement: GPD detector preserves compatible and debug outputs +The system SHALL return `PoseArray` outputs for compatibility while exposing richer GPD candidate/debug data when available. + +#### Scenario: GPD returns grasp poses +- **WHEN** the GPD backend returns one or more grasp candidates for a pointcloud +- **THEN** the detector returns a `PoseArray` in the configured output frame and publishes grasp pose output for visualization + +#### Scenario: GPD returns no grasps +- **WHEN** the GPD backend returns no grasp candidates for a valid pointcloud +- **THEN** the detector returns an empty `PoseArray` or `None` according to the existing `GraspGenSpec` behavior and reports an explicit empty-result message rather than failing silently + +#### Scenario: Candidate metadata is available for debugging +- **WHEN** the GPD backend exposes candidate scores, widths, or approach metadata +- **THEN** the detector publishes or records that metadata through debug/candidate outputs without requiring `GraspingModule` to consume a new contract + +### Requirement: GPD detector validates pointcloud conversion boundaries +The system SHALL validate the conversion between DimOS `PointCloud2` data produced by existing perception modules and the GPD backend input format before calling the native backend. + +#### Scenario: Valid pointcloud converts for GPD +- **WHEN** a `PointCloud2` contains valid XYZ data and frame metadata +- **THEN** the generator converts the pointcloud into the backend input representation and preserves the output frame relationship for returned poses + +#### Scenario: Invalid pointcloud is rejected clearly +- **WHEN** a pointcloud is empty, malformed, or lacks usable XYZ data +- **THEN** the generator fails the grasp generation call with a clear error or explicit empty-result response identifying the pointcloud problem + +### Requirement: GPD demo workflow reaches candidate generation without robot execution +The system SHALL include a documented end-to-end xArm MuJoCo demo command that starts simulation, uses existing perception modules to produce pointcloud/object data, routes a registered grasp target through `GraspingModule` and the GPD project-runtime worker, and visualizes the resulting grasp detection outputs in Rerun while stopping before robot execution. + +#### Scenario: xArm MuJoCo workflow invokes GPD +- **WHEN** the GPD MuJoCo demo runs and a configured grasp target becomes a registered object +- **THEN** the demo invokes `GraspingModule.generate_grasps(...)` for that target and routes the call to the placed GPD generator + +#### Scenario: User runs documented demo command +- **WHEN** the user follows the documented GPD grasp demo command after preparing the runtime +- **THEN** the command starts the xArm MuJoCo simulation, enables existing pointcloud/object perception modules, invokes GPD grasp detection for the configured grasp target, and exposes the outputs in Rerun + +#### Scenario: Demo does not execute robot motion +- **WHEN** the GPD MuJoCo demo produces grasp poses or an empty-result message +- **THEN** the demo does not command pick/place motion, gripper actuation, or robot execution as part of this change + +#### Scenario: Demo outputs are observable +- **WHEN** the GPD MuJoCo demo attempts grasp generation +- **THEN** grasp poses, candidate/debug outputs, or an explicit empty-result message are visible through normal DimOS outputs, logs, or Rerun visualization diff --git a/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/venv-module-packaging/spec.md b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/venv-module-packaging/spec.md new file mode 100644 index 0000000000..1b25abdad7 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/venv-module-packaging/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: GPD grasp demo package exercises pointcloud-based grasp detection +The system SHALL include a package-local GPD grasp demo package that prepares the pinned GPD native binding and uses it for grasp detection from pointcloud inputs produced by existing DimOS perception modules, not only import probing. + +#### Scenario: Demo package declares GPD grasp dependency closure +- **WHEN** the GPD grasp demo package is prepared as a Python project runtime +- **THEN** its project manifests declare the Python, DimOS, GPD, and native/Pixi dependency closure needed to run the GPD pointcloud-consuming grasp detector in the worker runtime + +#### Scenario: GPD import remains lazy +- **WHEN** the coordinator imports the GPD grasp demo package for blueprint construction +- **THEN** package import succeeds without importing `gpd.core` until worker-side grasp generation or an explicit worker-side probe runs + +#### Scenario: Demo package can run a real GPD generation path +- **WHEN** the GPD grasp demo package runtime is prepared and the generator receives a valid pointcloud +- **THEN** the worker process can import the pinned GPD binding and execute the adapter path used for grasp generation diff --git a/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/venv-module-placement/spec.md b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/venv-module-placement/spec.md new file mode 100644 index 0000000000..ecbde44506 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/specs/venv-module-placement/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Project runtime placement supports real GPD grasp detector Modules +The system SHALL support placing a real GPD grasp detector Module into a Python project runtime while preserving normal DimOS blueprint wiring and worker lifecycle behavior. + +#### Scenario: Blueprint places GPD generator into project runtime +- **WHEN** the GPD MuJoCo demo blueprint is built +- **THEN** the GPD grasp detector Module is assigned to the named GPD Python project runtime and other demo Modules remain in their normal runtime placements + +#### Scenario: GPD placement uses existing worker protocol +- **WHEN** the placed GPD generator receives lifecycle calls, stream wiring, or RPC calls +- **THEN** those operations use the same DimOS worker protocol semantics as other project-runtime Modules + +#### Scenario: GPD demo remains opt-in +- **WHEN** existing xArm perception or VGN demo blueprints are loaded +- **THEN** they do not implicitly include the GPD grasp generator or prepare/launch its project runtime diff --git a/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/tasks.md b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/tasks.md new file mode 100644 index 0000000000..7ec0ec35e4 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-gpd-grasp-detection-demo/tasks.md @@ -0,0 +1,33 @@ +## 1. Package and runtime setup + +- [x] 1.1 Create the import-safe `packages/dimos-gpd-grasp-demo/` Python project with `pyproject.toml`, `pixi.toml`, package source, and local `dimos` dependency wiring. +- [x] 1.2 Declare the pinned GPD dependency and native/Pixi dependencies needed for grasp detection from pointcloud inputs, reusing the proven GPD commit and direct-reference packaging settings. +- [x] 1.3 Add package tests proving coordinator-safe imports, lazy `gpd.core` import behavior, and project-runtime launch material resolution. + +## 2. GPD pointcloud-consuming grasp detector + +- [x] 2.1 Implement an import-safe `GPDGraspGenModule` that consumes existing `PointCloud2` inputs, implements `GraspGenSpec.generate_grasps(pointcloud, scene_pointcloud)`, and runs in a project runtime. +- [x] 2.2 Add conversion utilities from DimOS `PointCloud2` to the GPD backend input representation with clear handling for empty or malformed pointclouds. +- [x] 2.3 Convert GPD backend results into a compatible `PoseArray` while preserving frame metadata and publishing optional candidate/debug outputs. +- [x] 2.4 Add deterministic unit tests using synthetic/sample pointclouds and stubbed GPD backend responses for non-empty, empty, and invalid-input cases. + +## 3. Grasping workflow integration + +- [x] 3.1 Wire the GPD generator into a package-local blueprint factory with `PythonProjectRuntimeEnvironment` registration and placement for the generator Module. +- [x] 3.2 Add tests showing `GraspingModule.generate_grasps(...)` routes registered object pointclouds through the GPD generator via the existing `GraspGenSpec` pointcloud path. +- [x] 3.3 Verify `GraspingModule` publishes `PoseArray` results and returns explicit human-readable messages for empty or failed GPD outputs. + +## 4. xArm MuJoCo demo + +- [x] 4.1 Add an opt-in xArm MuJoCo GPD grasp demo blueprint modeled after `vgn_mujoco_grasp_demo`, using object registration, `GraspingModule`, the placed GPD generator, and `RerunBridgeModule`. +- [x] 4.2 Add a lightweight demo controller or reuse an existing controller pattern to wait for the configured registered grasp target and trigger `GraspingModule.generate_grasps(...)` without robot execution. +- [x] 4.3 Provide a documented command that runs the end-to-end demo with xArm MuJoCo simulation, existing pointcloud/object perception modules, GPD grasp detection, and Rerun visualization. +- [x] 4.4 Add blueprint-level tests proving the GPD demo is opt-in, uses stable remappings/output topics, places only the GPD generator into the project runtime, and does not modify existing VGN/xArm perception blueprints. +- [x] 4.5 Add an optional prepared-runtime integration test or demo smoke path that runs the full MuJoCo workflow when required runtime dependencies are available, skipping clearly otherwise. + +## 5. Documentation and validation + +- [x] 5.1 Document how to prepare the GPD grasp demo runtime and run the end-to-end xArm MuJoCo GPD grasp demo command. +- [x] 5.2 Document expected outputs, including grasp poses, optional candidate/debug outputs, empty-result behavior, and the fact that the first demo does not execute robot motion. +- [x] 5.3 Run focused tests for the GPD package, grasp generator, blueprint wiring, existing grasping/VGN tests, and runtime-environment regressions. +- [x] 5.4 Run ruff on touched files and validate the OpenSpec change strictly. diff --git a/openspec/specs/gpd-grasp-detection/spec.md b/openspec/specs/gpd-grasp-detection/spec.md new file mode 100644 index 0000000000..bd1474fd67 --- /dev/null +++ b/openspec/specs/gpd-grasp-detection/spec.md @@ -0,0 +1,61 @@ +## Purpose + +Define the GPD-based grasp detection workflow that consumes existing DimOS pointcloud perception outputs, produces compatible grasp pose outputs, and supports an end-to-end MuJoCo visualization demo without robot execution. + +## Requirements + +### Requirement: GPD detector consumes existing pointcloud inputs through the grasp generation contract +The system SHALL provide an import-safe GPD grasp detector Module that consumes existing DimOS `PointCloud2` inputs, implements the existing `GraspGenSpec` contract, and can be called by `GraspingModule.generate_grasps(...)`. + +#### Scenario: GraspingModule calls GPD through GraspGenSpec +- **WHEN** `GraspingModule.generate_grasps(...)` resolves a registered grasp target pointcloud from existing perception modules and a GPD detector is wired as `_grasp_gen` +- **THEN** the module calls `generate_grasps(pointcloud, scene_pointcloud)` on the GPD detector and publishes the returned `PoseArray` through the existing `grasps` output + +#### Scenario: GPD package import is coordinator-safe +- **WHEN** the coordinator imports the GPD grasp generator Module class for blueprint wiring +- **THEN** the import succeeds without importing `gpd.core` or other worker-only native dependencies at module import time + +### Requirement: GPD detector preserves compatible and debug outputs +The system SHALL return `PoseArray` outputs for compatibility while exposing richer GPD candidate/debug data when available. + +#### Scenario: GPD returns grasp poses +- **WHEN** the GPD backend returns one or more grasp candidates for a pointcloud +- **THEN** the detector returns a `PoseArray` in the configured output frame and publishes grasp pose output for visualization + +#### Scenario: GPD returns no grasps +- **WHEN** the GPD backend returns no grasp candidates for a valid pointcloud +- **THEN** the detector returns an empty `PoseArray` or `None` according to the existing `GraspGenSpec` behavior and reports an explicit empty-result message rather than failing silently + +#### Scenario: Candidate metadata is available for debugging +- **WHEN** the GPD backend exposes candidate scores, widths, or approach metadata +- **THEN** the detector publishes or records that metadata through debug/candidate outputs without requiring `GraspingModule` to consume a new contract + +### Requirement: GPD detector validates pointcloud conversion boundaries +The system SHALL validate the conversion between DimOS `PointCloud2` data produced by existing perception modules and the GPD backend input format before calling the native backend. + +#### Scenario: Valid pointcloud converts for GPD +- **WHEN** a `PointCloud2` contains valid XYZ data and frame metadata +- **THEN** the generator converts the pointcloud into the backend input representation and preserves the output frame relationship for returned poses + +#### Scenario: Invalid pointcloud is rejected clearly +- **WHEN** a pointcloud is empty, malformed, or lacks usable XYZ data +- **THEN** the generator fails the grasp generation call with a clear error or explicit empty-result response identifying the pointcloud problem + +### Requirement: GPD demo workflow reaches candidate generation without robot execution +The system SHALL include a documented end-to-end xArm MuJoCo demo command that starts simulation, uses existing perception modules to produce pointcloud/object data, routes a registered grasp target through `GraspingModule` and the GPD project-runtime worker, and visualizes the resulting grasp detection outputs in Rerun while stopping before robot execution. + +#### Scenario: xArm MuJoCo workflow invokes GPD +- **WHEN** the GPD MuJoCo demo runs and a configured grasp target becomes a registered object +- **THEN** the demo invokes `GraspingModule.generate_grasps(...)` for that target and routes the call to the placed GPD generator + +#### Scenario: User runs documented demo command +- **WHEN** the user follows the documented GPD grasp demo command after preparing the runtime +- **THEN** the command starts the xArm MuJoCo simulation, enables existing pointcloud/object perception modules, invokes GPD grasp detection for the configured grasp target, and exposes the outputs in Rerun + +#### Scenario: Demo does not execute robot motion +- **WHEN** the GPD MuJoCo demo produces grasp poses or an empty-result message +- **THEN** the demo does not command pick/place motion, gripper actuation, or robot execution as part of this change + +#### Scenario: Demo outputs are observable +- **WHEN** the GPD MuJoCo demo attempts grasp generation +- **THEN** grasp poses, candidate/debug outputs, or an explicit empty-result message are visible through normal DimOS outputs, logs, or Rerun visualization diff --git a/openspec/specs/venv-module-packaging/spec.md b/openspec/specs/venv-module-packaging/spec.md index 25bb7936a6..21f5a4aaad 100644 --- a/openspec/specs/venv-module-packaging/spec.md +++ b/openspec/specs/venv-module-packaging/spec.md @@ -73,3 +73,18 @@ The system SHALL include a GPD worker demo package that proves a Python binding #### Scenario: Pixi integration test is optional when Pixi is unavailable - **WHEN** the automated test environment does not have Pixi installed - **THEN** Pixi/GPD integration tests are skipped while uv-only and command-construction tests still run + +### Requirement: GPD grasp demo package exercises pointcloud-based grasp detection +The system SHALL include a package-local GPD grasp demo package that prepares the pinned GPD native binding and uses it for grasp detection from pointcloud inputs produced by existing DimOS perception modules, not only import probing. + +#### Scenario: Demo package declares GPD grasp dependency closure +- **WHEN** the GPD grasp demo package is prepared as a Python project runtime +- **THEN** its project manifests declare the Python, DimOS, GPD, and native/Pixi dependency closure needed to run the GPD pointcloud-consuming grasp detector in the worker runtime + +#### Scenario: GPD import remains lazy +- **WHEN** the coordinator imports the GPD grasp demo package for blueprint construction +- **THEN** package import succeeds without importing `gpd.core` until worker-side grasp generation or an explicit worker-side probe runs + +#### Scenario: Demo package can run a real GPD generation path +- **WHEN** the GPD grasp demo package runtime is prepared and the generator receives a valid pointcloud +- **THEN** the worker process can import the pinned GPD binding and execute the adapter path used for grasp generation diff --git a/openspec/specs/venv-module-placement/spec.md b/openspec/specs/venv-module-placement/spec.md index af298830e3..9769b498ce 100644 --- a/openspec/specs/venv-module-placement/spec.md +++ b/openspec/specs/venv-module-placement/spec.md @@ -88,3 +88,18 @@ The system SHALL keep Python project runtime placement scoped to active module p #### Scenario: Same Module can run without project runtime placement - **WHEN** a Module class appears in a blueprint without project runtime placement after previously being used with a project runtime - **THEN** the coordinator deploys it through the default Python worker pool + +### Requirement: Project runtime placement supports real GPD grasp detector Modules +The system SHALL support placing a real GPD grasp detector Module into a Python project runtime while preserving normal DimOS blueprint wiring and worker lifecycle behavior. + +#### Scenario: Blueprint places GPD generator into project runtime +- **WHEN** the GPD MuJoCo demo blueprint is built +- **THEN** the GPD grasp detector Module is assigned to the named GPD Python project runtime and other demo Modules remain in their normal runtime placements + +#### Scenario: GPD placement uses existing worker protocol +- **WHEN** the placed GPD generator receives lifecycle calls, stream wiring, or RPC calls +- **THEN** those operations use the same DimOS worker protocol semantics as other project-runtime Modules + +#### Scenario: GPD demo remains opt-in +- **WHEN** existing xArm perception or VGN demo blueprints are loaded +- **THEN** they do not implicitly include the GPD grasp generator or prepare/launch its project runtime diff --git a/packages/dimos-gpd-grasp-demo/pixi.lock b/packages/dimos-gpd-grasp-demo/pixi.lock new file mode 100644 index 0000000000..b16b3fd24f --- /dev/null +++ b/packages/dimos-gpd-grasp-demo/pixi.lock @@ -0,0 +1,4704 @@ +version: 7 +platforms: +- name: linux-64 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aiohttp-3.14.1-py312h5d8c7f2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.14.1-pl5321h039972f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils-2.45.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.45.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - 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/linux-64/cairo-1.18.4-he90730b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cli11-2.6.2-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cmake-4.3.4-hc85cc9f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/compilers-1.11.0-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.3.0-he8ccf15_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py312h0a2e395_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/eigen-5.0.1-hc65338a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/eigen-abi-5.0.1.80-hf414acd_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-8.1.2-gpl_h1bf8424_901.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/flann-1.9.2-he1b7b50_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fmt-12.1.0-hff5e90c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.1-h27c8c51_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.63.0-py312h8a5da7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fortran-compiler-1.11.0-h9bea470_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/frozenlist-1.8.0-py312h447239a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.3.0-h0dff253_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.3.0-h235f0fe_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.3.0-h50e9bb6_27.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran-14.3.0-he448592_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-14.3.0-h1a219da_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_linux-64-14.3.0-h5ce6d8a_27.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gl2ps-1.4.2-h36e74d4_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glew-2.3.0-h71661d4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glslang-16.3.0-h96af755_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-14.3.0-he448592_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-h2185e75_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-hd240bd5_27.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.2.1-h6083320_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.2.2-hde8ca8f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-gmmlib-22.10.0-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-media-driver-26.1.6-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/jsoncpp-1.9.7-h171cf75_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py312h0a2e395_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/level-zero-1.29.0-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.4-h96ad9f0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.4.2-hf998032_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-8_h4a7cf45_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-1.90.0-hd24cca6_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-devel-1.90.0-hfcd1e18_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-headers-1.90.0-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.78-hd0affe5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-8_h0358290_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.21.0-hcf29cc6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdovi-3.3.2-ha23c83e_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.127-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.2-h0d30a3d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.13.0-default_he001693_1000.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.2-h174a0a3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-8_h6ae95b6_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.8-hf7376ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-devel-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.10.0-nompi_hb6f1874_104.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopencv-5.0.0-headless_hf42b598_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2026.2.1-h7e124b3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2026.2.1-h7e124b3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2026.2.1-hd41364c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-npu-plugin-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-ir-frontend-2026.2.1-hd41364c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2026.2.1-h607c73d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2026.2.1-h607c73d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2026.2.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2026.2.1-h21c0c73_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2026.2.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libplacebo-7.360.1-h9eeb4b2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpq-18.4-hd5a49e9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-7.35.1-h3a69515_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libraqm-0.10.5-h75b3fb1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.3-h4c96295_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.3.0-h8f1669f_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-h084b8d7_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtheora-1.1.1-h4ab18f5_1006.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-h084b8d7_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libunwind-1.8.3-h65a8314_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liburing-2.14-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libva-2.23.0-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpl-2.16.0-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpx-1.15.2-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.341.0-h5279c79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbfile-1.2.0-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.0-py312h1c5ec97_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mesalib-26.0.3-h8cca3c9_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.32.9-hc50e24c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/msgpack-python-1.2.1-py312h0a2e395_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/multidict-6.7.1-py312h8a5da7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nanoflann-1.6.1-hff21bea_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.0-py312h33ff503_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.4-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/opencv-5.0.0-headless_h47a1348_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.4.13-hf734b31_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-h65dd3cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.30.1-h8d634f6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hda50119_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcl-1.15.1-h1259f1f_14.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/pillow-12.2.0-py312h50c33e8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/proj-9.8.1-he0df7b0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/propcache-0.5.2-py312h8a5da7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/py-opencv-5.0.0-headless_h75b7ce4_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.11.1-pl5321h16c4a6b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rhash-1.4.6-hb9d3cd8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.10-hdeec2a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2026.2-h718be3e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.2-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.3-hbc0de68_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.0.1-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-hab88423_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-devel-2023.0.0-hab88423_2.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/linux-64/unicodedata2-17.0.1-py312h4c3975b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/utfcpp-4.09-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.11.25-h26efc2c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/viskores-1.1.1-cpu_hc82bd48_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-9.6.2-py312h244374b_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-base-9.6.2-py312h5e1aeb2_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-io-ffmpeg-9.6.2-py312h14f7233_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxshmfence-1.3.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/yarl-1.24.2-py312h8a5da7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zfp-1.0.1-h909a3a2_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.3.0-hf649bbc_119.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.3.0-h9f08a49_119.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_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/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.49-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wslink-2.5.7-pyhd8ed1ab_0.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aiohttp-3.14.1-py312h5d8c7f2_0.conda + sha256: 7e03efaf3671cfca5d3195dc18aa3ca6bc182e0e34d30a95adb4f64f71d8f10c + md5: 10e4a93c73bfe09c5909cb1d41a1e40e + depends: + - __glibc >=2.17,<3.0.a0 + - aiohappyeyeballs >=2.5.0 + - aiosignal >=1.4.0 + - attrs >=17.3.0 + - frozenlist >=1.1.1 + - libgcc >=14 + - multidict >=4.5,<7.0 + - propcache >=0.2.0 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - typing_extensions >=4.4 + - yarl >=1.17.0,<2.0 + license: MIT AND Apache-2.0 + license_family: Apache + run_exports: {} + size: 1078273 + timestamp: 1780913823270 +- conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-hb03c661_0.conda + sha256: cf93ca0f1f107e95a35969a4622684e08fcb8cf37f8cf4a1e9e424828386c921 + md5: 8904e09bda369377b3dd07e2ac828c5d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-or-later + license_family: LGPL + run_exports: + weak: + - alsa-lib >=1.2.16.1,<1.3.0a0 + size: 592377 + timestamp: 1781521980743 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.14.1-pl5321h039972f_1.conda + sha256: b1d972a9b949a88babee681437535550b3ca5dbca6a23a40dffeb7900fec19fd + md5: 5a78a69eb3b50f24b379e9d2a93163ae + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - aom >=3.14.1,<3.15.0a0 + size: 3103347 + timestamp: 1780752473089 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils-2.45.1-default_h4852527_102.conda + sha256: 3c7c5580c1720206f28b7fa3d60d17986b3f32465e63009c14c9ae1ea64f926c + md5: 212fe5f1067445544c99dc1c847d032c + depends: + - binutils_impl_linux-64 >=2.45.1,<2.45.2.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 35436 + timestamp: 1774197482571 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda + sha256: 0a7d405064f53b9d91d92515f1460f7906ee5e8523f3cd8973430e81219f4917 + md5: 8165352fdce2d2025bf884dc0ee85700 + depends: + - ld_impl_linux-64 2.45.1 default_hbd61a6d_102 + - sysroot_linux-64 + - zstd >=1.5.7,<1.6.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 3661455 + timestamp: 1774197460085 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.45.1-default_h4852527_102.conda + sha256: 78a58d523d072b7f8e591b8f8572822e044b31764ed7e8d170392e7bc6d58339 + md5: 2a307a17309d358c9b42afdd3199ddcc + depends: + - binutils_impl_linux-64 2.45.1 default_hfdba357_102 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 36304 + timestamp: 1774197485247 +- conda: https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda + sha256: e7af5d1183b06a206192ff440e08db1c4e8b2ca1f8376ee45fb2f3a85d4ee45d + md5: 2c2fae981fd2afd00812c92ac47d023d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.1,<1.3.0a0 + - zstd >=1.5.6,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - blosc >=1.21.6,<2.0a0 + size: 48427 + timestamp: 1733513201413 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda + sha256: e511644d691f05eb12ebe1e971fd6dc3ae55a4df5c253b4e1788b789bdf2dfa6 + md5: 8ccf913aaba749a5496c17629d859ed1 + depends: + - __glibc >=2.17,<3.0.a0 + - brotli-bin 1.2.0 hb03c661_1 + - libbrotlidec 1.2.0 hb03c661_1 + - libbrotlienc 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + size: 20103 + timestamp: 1764017231353 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda + sha256: 64b137f30b83b1dd61db6c946ae7511657eead59fdf74e84ef0ded219605aa94 + md5: af39b9a8711d4a8d437b52c1d78eb6a1 + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlidec 1.2.0 hb03c661_1 + - libbrotlienc 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: {} + size: 21021 + timestamp: 1764017221344 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 + md5: d2ffd7602c02f2b316fd921d39876885 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 260182 + timestamp: 1771350215188 +- conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda + sha256: cc9accf72fa028d31c2a038460787751127317dcfa991f8d1f1babf216bb454e + md5: 920bb03579f15389b9e512095ad995b7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - c-ares >=1.34.6,<2.0a0 + size: 207882 + timestamp: 1765214722852 +- conda: https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda + sha256: 8e7a40f16400d7839c82581410aa05c1f8324a693c9d50079f8c50dc9fb241f0 + md5: abd85120de1187b0d1ec305c2173c71b + depends: + - binutils + - gcc + - gcc_linux-64 14.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6693 + timestamp: 1753098721814 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda + sha256: 06525fa0c4e4f56e771a3b986d0fdf0f0fc5a3270830ee47e127a5105bde1b9a + md5: bb6c4808bfa69d6f7f6b07e5846ced37 + depends: + - __glibc >=2.17,<3.0.a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - icu >=78.1,<79.0a0 + - libexpat >=2.7.3,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libgcc >=14 + - libglib >=2.86.3,<3.0a0 + - libpng >=1.6.53,<1.7.0a0 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pixman >=0.46.4,<1.0a0 + - xorg-libice >=1.1.2,<2.0a0 + - xorg-libsm >=1.2.6,<2.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + license: LGPL-2.1-only or MPL-1.1 + run_exports: + weak: + - cairo >=1.18.4,<2.0a0 + size: 989514 + timestamp: 1766415934926 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cli11-2.6.2-h54a6638_0.conda + sha256: 1d635e8963e094d95d35148df4b46e495f93bb0750ad5069f4e0e6bbb47ac3bf + md5: 83dae3dfadcfec9b37a9fbff6f7f7378 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 99051 + timestamp: 1772207728613 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cmake-4.3.4-hc85cc9f_0.conda + sha256: a3369cbf4c8d77a3398c3c2bb1e7d72af26ac9c655e4753964bdb744bf1e5e8c + md5: aa9174a98942599973baf4c5639e13b5 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libcurl >=8.20.0,<9.0a0 + - libexpat >=2.8.1,<3.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libstdcxx >=14 + - libuv >=1.52.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - rhash >=1.4.6,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 23168153 + timestamp: 1781778936323 +- conda: https://conda.anaconda.org/conda-forge/linux-64/compilers-1.11.0-ha770c72_0.conda + sha256: 5709f2cbfeb8690317ba702611bdf711a502b417fecda6ad3313e501f6f8bd61 + md5: fdcf2e31dd960ef7c5daa9f2c95eff0e + depends: + - c-compiler 1.11.0 h4d9bdce_0 + - cxx-compiler 1.11.0 hfcd1e18_0 + - fortran-compiler 1.11.0 h9bea470_0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 7482 + timestamp: 1753098722454 +- conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.3.0-he8ccf15_19.conda + sha256: 769357d82d19f59c23888d935d92b67739bdb2acaacaa7bbe7c4fe4ee5346287 + md5: fd57230e9a97b97bf20dd63aeae6fe61 + depends: + - gcc_impl_linux-64 >=14.3.0,<14.3.1.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 31751 + timestamp: 1778268677594 +- conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py312h0a2e395_4.conda + sha256: 62447faf7e8eb691e407688c0b4b7c230de40d5ecf95bf301111b4d05c5be473 + md5: 43c2bc96af3ae5ed9e8a10ded942aa50 + depends: + - numpy >=1.25 + - python + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 320386 + timestamp: 1769155979897 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda + sha256: 3fcc97ae3e89c150401a50a4de58794ffc67b1ed0e1851468fcc376980201e25 + md5: 5da8c935dca9186673987f79cef0b2a5 + depends: + - c-compiler 1.11.0 h4d9bdce_0 + - gxx + - gxx_linux-64 14.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6635 + timestamp: 1753098722177 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda + sha256: 7684da83306bb69686c0506fb09aa7074e1a55ade50c3a879e4e5df6eebb1009 + md5: af491aae930edc096b58466c51c4126c + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=13 + - libntlm >=1.8,<2.0a0 + - libstdcxx >=13 + - libxcrypt >=4.4.36 + - openssl >=3.5.5,<4.0a0 + license: BSD-3-Clause-Attribution + license_family: BSD + run_exports: + weak: + - cyrus-sasl >=2.1.28,<3.0a0 + size: 210103 + timestamp: 1771943128249 +- conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda + sha256: 22053a5842ca8ee1cf8e1a817138cdb5e647eb2c46979f84153f6ad7bde73020 + md5: 418c6ca5929a611cbd69204907a83995 + depends: + - libgcc-ng >=12 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - dav1d >=1.2.1,<1.2.2.0a0 + size: 760229 + timestamp: 1685695754230 +- conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + sha256: 8bb557af1b2b7983cf56292336a1a1853f26555d9c6cecf1e5b2b96838c9da87 + md5: ce96f2f470d39bd96ce03945af92e280 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - libglib >=2.86.2,<3.0a0 + - libexpat >=2.7.3,<3.0a0 + license: AFL-2.1 OR GPL-2.0-or-later + run_exports: + weak: + - dbus >=1.16.2,<2.0a0 + size: 447649 + timestamp: 1764536047944 +- conda: https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda + sha256: 40cdd1b048444d3235069d75f9c8e1f286db567f6278a93b4f024e5642cfaecc + md5: dbe3ec0f120af456b3477743ffd99b74 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - double-conversion >=3.4.0,<3.5.0a0 + size: 71809 + timestamp: 1765193127016 +- conda: https://conda.anaconda.org/conda-forge/linux-64/eigen-5.0.1-hc65338a_0.conda + sha256: f122c5bb618532eb40124f34dc3d467b9142c4a573c206e3e6a51df671345d6a + md5: fcc98d38ae074ee72ee9152e357bcbf2 + license: MPL-2.0 + license_family: MOZILLA + run_exports: {} + size: 1311364 + timestamp: 1773744756289 +- conda: https://conda.anaconda.org/conda-forge/linux-64/eigen-abi-5.0.1.80-hf414acd_0.conda + sha256: acde70d55f7dbbc043d733427fd6f1ec6ca49db7cbabcf7a656dd29a952b8ad8 + md5: a85c1768af7780d67c6446c17848b76f + constrains: + - eigen >=5.0.1,<5.0.2.0a0 + license: MPL-2.0 + license_family: MOZILLA + run_exports: {} + size: 13265 + timestamp: 1773744756289 +- conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.8.1-hecca717_1.conda + sha256: bc1e8177a4ebbbfcda98b6f4a1575f3337e69f0d2f1025a84570533ca27b89eb + md5: e211a78613b9d50a096644b97cede8f7 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat 2.8.1 hecca717_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libexpat >=2.8.1,<3.0a0 + size: 147797 + timestamp: 1781203608158 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-8.1.2-gpl_h1bf8424_901.conda + sha256: 2d56b0d90a1e581e296a41aa0a0443c85f918a94780e779a23005be9128627be + md5: 0c457f1b2384bb0aa984831a79021a66 + depends: + - __glibc >=2.17,<3.0.a0 + - alsa-lib >=1.2.16.1,<1.3.0a0 + - aom >=3.14.1,<3.15.0a0 + - bzip2 >=1.0.8,<2.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - gmp >=6.3.0,<7.0a0 + - harfbuzz >=14.2.1 + - lame >=3.100,<3.101.0a0 + - libass >=0.17.4,<0.17.5.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libjxl >=0.11,<1.0a0 + - liblzma >=5.8.3,<6.0a0 + - libopenvino >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-batch-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-hetero-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-cpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-gpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-npu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-ir-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-onnx-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-paddle-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-pytorch-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-lite-frontend >=2026.2.1,<2026.2.2.0a0 + - libopus >=1.6.1,<2.0a0 + - libplacebo >=7.360.1,<7.361.0a0 + - librsvg >=2.62.3,<3.0a0 + - libstdcxx >=14 + - libva >=2.23.0,<3.0a0 + - libvorbis >=1.3.7,<1.4.0a0 + - libvpl >=2.16.0,<2.17.0a0 + - libvpx >=1.15.2,<1.16.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openh264 >=2.6.0,<2.6.1.0a0 + - openssl >=3.5.7,<4.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - sdl2 >=2.32.56,<3.0a0 + - shaderc >=2026.2,<2026.3.0a0 + - svt-av1 >=4.0.1,<4.0.2.0a0 + - x264 >=1!164.3095,<1!165 + - x265 >=3.5,<3.6.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + constrains: + - __cuda >=12.8 + license: GPL-2.0-or-later + license_family: GPL + run_exports: + weak: + - ffmpeg >=8.1.2,<9.0a0 + size: 13078770 + timestamp: 1782260267617 +- conda: https://conda.anaconda.org/conda-forge/linux-64/flann-1.9.2-he1b7b50_6.conda + sha256: 690c6e8204678d40fff369c96110735a1a6f0b47256359676aa26176a151a4a6 + md5: e5d172b12683fccd78ab3446c9a29707 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libgcc >=14 + - libstdcxx >=14 + - lz4-c >=1.10.0,<1.11.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - flann >=1.9.2,<1.9.3.0a0 + size: 1990499 + timestamp: 1774331944832 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fmt-12.1.0-hff5e90c_0.conda + sha256: d4e92ba7a7b4965341dc0fca57ec72d01d111b53c12d11396473115585a9ead6 + md5: f7d7a4104082b39e3b3473fbd4a38229 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - fmt >=12.1.0,<12.2.0a0 + size: 198107 + timestamp: 1767681153946 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.1-h27c8c51_0.conda + sha256: 2e50bdcebdf70a865b81f2456bbc586386451ec601c60f2b6cd22b8c40a2d384 + md5: e0e050cfa9fa85fe39632ab11cb7f3e0 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libuuid >=2.42.1,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + size: 281880 + timestamp: 1780450077431 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.63.0-py312h8a5da7c_0.conda + sha256: d235ae7075642044ceb3d922ef2a710a82665755ac9bbb7e8dad7daa72bc6d87 + md5: 294fb524171e2a2748cb7fe708aba826 + depends: + - __glibc >=2.17,<3.0.a0 + - brotli + - libgcc >=14 + - munkres + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - unicodedata2 >=15.1.0 + license: MIT + license_family: MIT + run_exports: {} + size: 3007892 + timestamp: 1778770568019 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fortran-compiler-1.11.0-h9bea470_0.conda + sha256: 53e5562ede83b478ebe9f4fc3d3b4eff5b627883f48aa0bf412e8fd90b5d6113 + md5: d5596f445a1273ddc5ea68864c01b69f + depends: + - binutils + - c-compiler 1.11.0 h4d9bdce_0 + - gfortran + - gfortran_linux-64 14.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6656 + timestamp: 1753098722318 +- conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda + sha256: c934c385889c7836f034039b43b05ccfa98f53c900db03d8411189892ced090b + md5: 8462b5322567212beeb025f3519fb3e2 + depends: + - libfreetype 2.14.3 ha770c72_0 + - libfreetype6 2.14.3 h73754d4_0 + license: GPL-2.0-only OR FTL + run_exports: + weak: + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + size: 173839 + timestamp: 1774298173462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda + sha256: 858283ff33d4c033f4971bf440cebff217d5552a5222ba994c49be990dacd40d + md5: f9f81ea472684d75b9dd8d0b328cf655 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-or-later + run_exports: + weak: + - fribidi >=1.0.16,<2.0a0 + size: 61244 + timestamp: 1757438574066 +- conda: https://conda.anaconda.org/conda-forge/linux-64/frozenlist-1.8.0-py312h447239a_0.conda + sha256: 7f36a4fc42f6d4cb9c5b210b6604b54eba2e5745c92d76241b6f8fce446818d1 + md5: 6a42923f35087cc88a9fac31ef096ce6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 55016 + timestamp: 1779999817627 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.3.0-h0dff253_19.conda + sha256: e3f541c4be7296b800a972482b7a5e399614d5e3310d3d083257fbdb4df81ea0 + md5: 2dd149aa693db92758af3e685ef30439 + depends: + - conda-gcc-specs + - gcc_impl_linux-64 14.3.0 h235f0fe_19 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 29459 + timestamp: 1778268802660 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.3.0-h235f0fe_19.conda + sha256: 1e2500ca976d4831c953d1c6db7b238d2e6806910b930e3eb631b79ba5c3ba41 + md5: 99936dc616b7ce97b0468759b8a7c64e + depends: + - binutils_impl_linux-64 >=2.45 + - libgcc >=14.3.0 + - libgcc-devel_linux-64 14.3.0 hf649bbc_119 + - libgomp >=14.3.0 + - libsanitizer 14.3.0 h8f1669f_19 + - libstdcxx >=14.3.0 + - libstdcxx-devel_linux-64 14.3.0 h9f08a49_119 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 77667192 + timestamp: 1778268558509 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.3.0-h50e9bb6_27.conda + sha256: d7f427d6db94a5eed2a43b186f8dc17cc1fbeb03517442390a36f1cde02548b0 + md5: 90369fa27fae2f7bf7eaaccc34c793e2 + depends: + - gcc_impl_linux-64 14.3.0.* + - binutils_linux-64 + - sysroot_linux-64 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - libgcc >=14 + size: 29324 + timestamp: 1781279939286 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + sha256: 1c22e37f9d7e06e9e0582ee5a55c2ddd19ea75f71f44eb13b56f504ef5c37aa5 + md5: 5d355db3e937086e22cf4cb5fe19787c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libglib >=2.88.2,<3.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + run_exports: + weak: + - gdk-pixbuf >=2.44.7,<3.0a0 + size: 581631 + timestamp: 1782591374199 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran-14.3.0-he448592_7.conda + sha256: 7ab008dd3dc5e6ca0de2614771019a1d35480d51df6216c96b1cf6a5e660ee40 + md5: 94394acdc56dcb4d55dddf0393134966 + depends: + - gcc 14.3.0.* + - gcc_impl_linux-64 14.3.0.* + - gfortran_impl_linux-64 14.3.0.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 30407 + timestamp: 1759966108779 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-14.3.0-h1a219da_19.conda + sha256: aee591aab674d70829ecc1abaa7f2ad5fcccbfad7bafa5ebca1225c86c60f18f + md5: d5f5c8cc2a64220838a096041b7a7fb4 + depends: + - gcc_impl_linux-64 >=14.3.0 + - libgcc >=14.3.0 + - libgfortran5 >=14.3.0 + - libstdcxx >=14.3.0 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 18551249 + timestamp: 1778268735401 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_linux-64-14.3.0-h5ce6d8a_27.conda + sha256: b8e6bd99f0b7a5e8757a1cf9bf64edc6217206d8872fb9af5d8204e5b581b3d1 + md5: cedd6fb9fad7611ee0bcb0fb531d77f1 + depends: + - gfortran_impl_linux-64 14.3.0.* + - gcc_linux-64 ==14.3.0 h50e9bb6_27 + - binutils_linux-64 + - sysroot_linux-64 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - libgfortran5 >=14.3.0 + - libgfortran + - libgcc >=14 + size: 27545 + timestamp: 1781279939286 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gl2ps-1.4.2-h36e74d4_2.conda + sha256: e28a214c71590a09f75f1aaccf5795bbcfb99b00c2d6ef55d34320b4f47485bd + md5: 787c780ff43f9f79d78d01e476b81a7c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libpng >=1.6.55,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + run_exports: + weak: + - gl2ps >=1.4.2,<1.4.3.0a0 + size: 75835 + timestamp: 1773985381918 +- conda: https://conda.anaconda.org/conda-forge/linux-64/glew-2.3.0-h71661d4_0.conda + sha256: 535d152ee06e3d3015a5ab410dfea9574e1678e226fa166f859a0b9e1153e597 + md5: 7eefecda1c71c380bfc406d16e78bbee + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglu >=9.0.3,<9.1.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - glew >=2.3.0,<2.4.0a0 + size: 492673 + timestamp: 1766373546677 +- conda: https://conda.anaconda.org/conda-forge/linux-64/glslang-16.3.0-h96af755_0.conda + sha256: 3c9b6a90937a96ad27d160304cdbe5e9961db613aba2b84ff673429f0c61d48e + md5: d175cb2c14104728ada04883786a309d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - spirv-tools >=2026,<2027.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - glslang >=16,<17.0a0 + size: 1366082 + timestamp: 1777747028121 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda + sha256: 309cf4f04fec0c31b6771a5809a1909b4b3154a2208f52351e1ada006f4c750c + md5: c94a5994ef49749880a8139cf9afcbe1 + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: GPL-2.0-or-later OR LGPL-3.0-or-later + run_exports: + weak: + - gmp >=6.3.0,<7.0a0 + size: 460055 + timestamp: 1718980856608 +- conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-hecca717_0.conda + sha256: 885fa7d1d7e2ad9ed0a700ee0d81ceb49de278253082d517959b22d6336eecce + md5: cf09e9fc938518e91d0706572cadf17a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: LGPL-2.0-or-later + license_family: LGPL + run_exports: + weak: + - graphite2 >=1.3.15,<2.0a0 + size: 100054 + timestamp: 1780454302233 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-14.3.0-he448592_7.conda + sha256: 7acf0ee3039453aa69f16da063136335a3511f9c157e222def8d03c8a56a1e03 + md5: 91dc0abe7274ac5019deaa6100643265 + depends: + - gcc 14.3.0.* + - gxx_impl_linux-64 14.3.0.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 30403 + timestamp: 1759966121169 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-h2185e75_19.conda + sha256: a31694c26d6a525d44f81130ebf7b9abe18771b7eaecb2cf93630c0b8b8fb936 + md5: 8b867d053ed89743eeac52c3a50f112d + depends: + - gcc_impl_linux-64 14.3.0 h235f0fe_19 + - libstdcxx-devel_linux-64 14.3.0 h9f08a49_119 + - sysroot_linux-64 + - tzdata + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 15235650 + timestamp: 1778268773535 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-hd240bd5_27.conda + sha256: cf5a44d14732b79e3c2bc6ebe1dba4f6b9077939f3093c75e36ad340737b5c15 + md5: 41fae38a424bbc78438cfec6a4fde187 + depends: + - gxx_impl_linux-64 14.3.0.* + - gcc_linux-64 ==14.3.0 h50e9bb6_27 + - binutils_linux-64 + - sysroot_linux-64 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - libstdcxx >=14 + - libgcc >=14 + size: 27854 + timestamp: 1781279939286 +- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.2.1-h6083320_0.conda + sha256: da9901aa1e20cbc2369fda212039b294dd02bce95f005539bab840b7310bf7d0 + md5: 21ee4640b7c2d94e584349fa12b29b9a + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - graphite2 >=1.3.14,<2.0a0 + - icu >=78.3,<79.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.1,<3.0a0 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - harfbuzz >=14.2.1 + size: 2362258 + timestamp: 1780450503234 +- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda + sha256: 0d09b6dc1ce5c4005ae1c6a19dc10767932ef9a5e9c755cfdbb5189ac8fb0684 + md5: bd77f8da987968ec3927990495dc22e4 + depends: + - libgcc-ng >=12 + - libjpeg-turbo >=3.0.0,<4.0a0 + - libstdcxx-ng >=12 + - libzlib >=1.2.13,<2.0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - hdf4 >=4.2.15,<4.2.16.0a0 + size: 756742 + timestamp: 1695661547874 +- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda + sha256: ac57ce3abbda2a82d6192bc36fbd7fe96e867d84c999ef81a3da034dfd01a995 + md5: 9c6e11a83468e1d5decae516077a73fb + depends: + - __glibc >=2.17,<3.0.a0 + - libaec >=1.1.5,<2.0a0 + - libcurl >=8.20.0,<9.0a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.6,<4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - hdf5 >=1.14.6,<1.14.7.0a0 + size: 3721555 + timestamp: 1780581675871 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a + md5: c80d8a3b84358cb967fa81e7075fbc8a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 12723451 + timestamp: 1773822285671 +- conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.2.2-hde8ca8f_0.conda + sha256: 43f30e6fd8cbe1fef59da760d1847c9ceff3fb69ceee7fd4a34538b0927959dd + md5: c427448c6f3972c76e8a4474e0fe367b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - imath >=3.2.2,<3.2.3.0a0 + size: 160289 + timestamp: 1759983212466 +- conda: https://conda.anaconda.org/conda-forge/linux-64/intel-gmmlib-22.10.0-hb700be7_0.conda + sha256: bc231d69eb6663db0e09738fb916c5e5507147cf1ac60f364f964004e0b29bab + md5: 10909406c1b0e4b57f9f4f0eb0999af8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - intel-gmmlib >=22.10.0,<23.0a0 + size: 1013714 + timestamp: 1774422680665 +- conda: https://conda.anaconda.org/conda-forge/linux-64/intel-media-driver-26.1.6-hecca717_0.conda + sha256: 7cbd7fda22db70c64af64c9173434a4ede58e4f220bda52a044e469aa94c65cb + md5: aaf7c3db8c7c4533deb5449d3ba1c51f + depends: + - __glibc >=2.17,<3.0.a0 + - intel-gmmlib >=22.10.0,<23.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libva >=2.23.0,<3.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - intel-media-driver >=26.1.6,<26.2.0a0 + size: 8782375 + timestamp: 1776080148587 +- conda: https://conda.anaconda.org/conda-forge/linux-64/jsoncpp-1.9.7-h171cf75_3.conda + sha256: 51caa2b459c12fe7030abd4a9923e92a323d7c98ab5136fc78d635bd4e88e17e + md5: b71129c871a095d84aea2ad89b00fc33 + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: LicenseRef-Public-Domain OR MIT + run_exports: + weak: + - jsoncpp >=1.9.7,<1.9.8.0a0 + size: 187937 + timestamp: 1782749213292 +- conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + sha256: 0960d06048a7185d3542d850986d807c6e37ca2e644342dd0c72feefcf26c2a4 + md5: b38117a3c920364aff79f870c984b4a3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-or-later + run_exports: + weak: + - keyutils >=1.6.3,<2.0a0 + size: 134088 + timestamp: 1754905959823 +- conda: https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py312h0a2e395_0.conda + sha256: eec7654c2d68f06590862c6e845cc70987b6d6559222b6f0e619dea4268f5dd5 + md5: cd74a9525dc74bbbf93cf8aa2fa9eb5b + depends: + - python + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 77120 + timestamp: 1773067050308 +- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + sha256: 9b07046870772f28740e3f6149f09ff222843733087a33c5540b169c6289652d + md5: 54157a1c8c0bb70f62dd0b17fba7e7f2 + depends: + - __glibc >=2.17,<3.0.a0 + - keyutils >=1.6.3,<2.0a0 + - libedit >=3.1.20250104,<3.2.0a0 + - libedit >=3.1.20250104,<4.0a0 + - libgcc >=14 + - libstdcxx >=14 + - openssl >=3.5.7,<4.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - krb5 >=1.22.2,<1.23.0a0 + size: 1388990 + timestamp: 1781859420533 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 + sha256: aad2a703b9d7b038c0f745b853c6bb5f122988fe1a7a096e0e606d9cbec4eaab + md5: a8832b479f93521a9e7b5b743803be51 + depends: + - libgcc-ng >=12 + license: LGPL-2.0-only + license_family: LGPL + run_exports: + weak: + - lame >=3.100,<3.101.0a0 + size: 508258 + timestamp: 1664996250081 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.conda + sha256: 112b5b9462572d970f4abd2912f76a25ee7db158b1e7260163d91dd8a630db84 + md5: 8b3ce45e929cd8e8e5f4d18586b56d8b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - lcms2 >=2.19.1,<3.0a0 + size: 251971 + timestamp: 1780211695895 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c + md5: 18335a698559cdbcd86150a48bf54ba6 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 728002 + timestamp: 1774197446916 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda + sha256: f84cb54782f7e9cea95e810ea8fef186e0652d0fa73d3009914fa2c1262594e1 + md5: a752488c68f2e7c456bcbd8f16eec275 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - lerc >=4.1.0,<5.0a0 + size: 261513 + timestamp: 1773113328888 +- conda: https://conda.anaconda.org/conda-forge/linux-64/level-zero-1.29.0-hb700be7_0.conda + sha256: d87cfc5eaa08eefff97d891ecb49faa958fcfc32a425767796269c4100d4e516 + md5: f3c3bc77c96af553f761af0e78bc8d9d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: {} + size: 875773 + timestamp: 1780142086148 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda + sha256: 32933de2d4fa6e6ffd949052815b49cb65a0649ad70007155c533ab97ea8cefd + md5: c4393db381bffa0a83a8d9e47b238106 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - abseil-cpp =20260526.0 + - libabseil-static =20260526.0=cxx17* + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - libabseil >=20260526.0,<20260527.0a0 + - libabseil =*=cxx17* + size: 1437712 + timestamp: 1780524559298 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda + sha256: 822e4ae421a7e9c04e841323526321185f6659222325e1a9aedec811c686e688 + md5: 86f7414544ae606282352fa1e116b41f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libaec >=1.1.5,<2.0a0 + size: 36544 + timestamp: 1769221884824 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.4-h96ad9f0_0.conda + sha256: 035eb8b54e03e72e42ef707420f9979c7427776ea99e0f1e3c969f92eb573f19 + md5: d3be7b2870bf7aff45b12ea53165babd + depends: + - libgcc >=13 + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + - libfreetype >=2.13.3 + - libfreetype6 >=2.13.3 + - fribidi >=1.0.10,<2.0a0 + - libiconv >=1.18,<2.0a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - harfbuzz >=11.0.1 + license: ISC + run_exports: + weak: + - libass >=0.17.4,<0.17.5.0a0 + size: 152179 + timestamp: 1749328931930 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.4.2-hf998032_1.conda + sha256: b831161d7c86f7dfeb58d7189176c3314d7c2ccb38b4e1c8b376557ae4238f0b + md5: 57845550bac29aeb754615c41b518f74 + depends: + - __glibc >=2.17,<3.0.a0 + - aom >=3.14.1,<3.15.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - libgcc >=14 + - rav1e >=0.8.1,<0.9.0a0 + - svt-av1 >=4.0.1,<4.0.2.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libavif16 >=1.4.2,<2.0a0 + size: 149787 + timestamp: 1780665914755 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-8_h4a7cf45_openblas.conda + build_number: 8 + sha256: b2da6bfd72a1c9cb143ccf64bf5b28790cb4eb58bd1cb978f6537b2322f7d48b + md5: 00fc660ab1b2f5ca07e92b4900d10c79 + depends: + - libopenblas >=0.3.33,<0.3.34.0a0 + - libopenblas >=0.3.33,<1.0a0 + constrains: + - blas 2.308 openblas + - mkl <2027 + - libcblas 3.11.0 8*_openblas + - liblapack 3.11.0 8*_openblas + - liblapacke 3.11.0 8*_openblas + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libblas >=3.11.0,<4.0a0 + size: 18804 + timestamp: 1779859100675 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-1.90.0-hd24cca6_1.conda + sha256: fef9f2977ac341fd0fd7802bccffff0f220e4896f6fef29040428071d0aa863b + md5: 4dfa9b413062a24e09938fb6f91af821 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - icu >=78.1,<79.0a0 + - libgcc >=14 + - liblzma >=5.8.1,<6.0a0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - boost-cpp <0.0a0 + license: BSL-1.0 + run_exports: {} + size: 3229874 + timestamp: 1766347309472 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-devel-1.90.0-hfcd1e18_1.conda + sha256: a46970575b8ec39fe103833ed988bc9d56292146b417aeddb333a94e980053b0 + md5: 5e5227b43bdd65d0028a322b2636d02e + depends: + - libboost 1.90.0 hd24cca6_1 + - libboost-headers 1.90.0 ha770c72_1 + constrains: + - boost-cpp <0.0a0 + license: BSL-1.0 + run_exports: + weak: + - libboost >=1.90.0,<1.91.0a0 + size: 38562 + timestamp: 1766347475462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libboost-headers-1.90.0-ha770c72_1.conda + sha256: be43ec008a4fd297b5cdccea6c8e05bedd1d40315bdaefe4cc2f75363dab3f73 + md5: a1da1e4c15eb57bb506b33e283107dc5 + constrains: + - boost-cpp <0.0a0 + license: BSL-1.0 + run_exports: {} + size: 14676487 + timestamp: 1766347330772 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + sha256: 318f36bd49ca8ad85e6478bd8506c88d82454cc008c1ac1c6bf00a3c42fa610e + md5: 72c8fd1af66bd67bf580645b426513ed + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlicommon >=1.2.0,<1.3.0a0 + size: 79965 + timestamp: 1764017188531 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + sha256: 12fff21d38f98bc446d82baa890e01fd82e3b750378fedc720ff93522ffb752b + md5: 366b40a69f0ad6072561c1d09301c886 + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlidec >=1.2.0,<1.3.0a0 + size: 34632 + timestamp: 1764017199083 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + sha256: a0c15c79997820bbd3fbc8ecf146f4fe0eca36cc60b62b63ac6cf78857f1dd0d + md5: 4ffbb341c8b616aa2494b6afb26a0c5f + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlienc >=1.2.0,<1.3.0a0 + size: 298378 + timestamp: 1764017210931 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.78-hd0affe5_0.conda + sha256: cc8c9fc6ddf0fbd3d1275b558ae9abad6cda23bced268732e2da21a87bb358cd + md5: f9f17eab7f3df1c6fd4b1a548a2f683a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libcap >=2.78,<2.79.0a0 + size: 124335 + timestamp: 1775488792584 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-8_h0358290_openblas.conda + build_number: 8 + sha256: 1a2bc77bb26520255904a3d9b1f40e6bf0bf9d8d3405c7709dd162282820915a + md5: 33a413f1095f8325e5c30fde3b0d2445 + depends: + - libblas 3.11.0 8_h4a7cf45_openblas + constrains: + - blas 2.308 openblas + - liblapacke 3.11.0 8*_openblas + - liblapack 3.11.0 8*_openblas + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libcblas >=3.11.0,<4.0a0 + size: 18778 + timestamp: 1779859107964 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda + sha256: 205c4f19550f3647832ec44e35e6d93c8c206782bdd620c1d7cf66237580ff9c + md5: 49c553b47ff679a6a1e9fc80b9c5a2d4 + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - libcups >=2.3.3,<2.4.0a0 + size: 4518030 + timestamp: 1770902209173 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.21.0-hcf29cc6_0.conda + sha256: 93ab25f02666eb8696fa70d9c920f2e3b370742ee17e2758705038a72e11147f + md5: dcd79cabd5d435f48d75db3d81cb5874 + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - libnghttp2 >=1.68.1,<2.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.7,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + run_exports: + weak: + - libcurl >=8.21.0,<9.0a0 + size: 479582 + timestamp: 1782296544301 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda + sha256: aa8e8c4be9a2e81610ddf574e05b64ee131fab5e0e3693210c9d6d2fba32c680 + md5: 6c77a605a7a689d17d4819c0f8ac9a00 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libdeflate >=1.25,<1.26.0a0 + size: 73490 + timestamp: 1761979956660 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdovi-3.3.2-ha23c83e_4.conda + sha256: d15432f07f654583978712e034d308b103a8b4650f0fdec172b5031a8af2b6c9 + md5: b26a64dfb24fef32d3330e37ce5e4f44 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + run_exports: + weak: + - libdovi >=3.3.2,<4.0a0 + size: 311420 + timestamp: 1777838991858 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.127-hb03c661_0.conda + sha256: 7d3187c11b7ae66c5595a8afd5a7ce352a490527fdf6614cab129bc7f2c16ba3 + md5: d8d16b9b32a3c5df7e5b3350e2cbe058 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpciaccess >=0.19,<0.20.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libdrm >=2.4.127,<2.5.0a0 + size: 311505 + timestamp: 1778975798004 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + sha256: d789471216e7aba3c184cd054ed61ce3f6dac6f87a50ec69291b9297f8c18724 + md5: c277e0a4d549b03ac1e9d6cbbe3d017b + depends: + - ncurses + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libedit >=3.1.20250104,<3.2.0a0 + size: 134676 + timestamp: 1738479519902 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + sha256: 9a25ea93e8272785405a21d30f84e620befb1d545f6dfaae18f06103b5df0443 + md5: 75e9f795be506c96dd43cb09c7c8d557 + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: {} + size: 46500 + timestamp: 1779728188901 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_3.conda + sha256: e4b46919c9bb65930bce238bd2736110ed7b8c30e5cd5394e4e1edb48de54843 + md5: 5bc6d55503483aabe8a90c5e7f49a2a4 + depends: + - __glibc >=2.17,<3.0.a0 + - libegl 1.7.0 ha4b6fd6_3 + - libgl-devel 1.7.0 ha4b6fd6_3 + - xorg-libx11 + license: LicenseRef-libglvnd + run_exports: + weak: + - libegl >=1.7.0,<2.0a0 + size: 31718 + timestamp: 1779728222280 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda + sha256: 1cd6048169fa0395af74ed5d8f1716e22c19a81a8a36f934c110ca3ad4dd27b4 + md5: 172bf1cd1ff8629f2b1179945ed45055 + depends: + - libgcc-ng >=12 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libev >=4.33,<4.34.0a0 + size: 112766 + timestamp: 1702146165126 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 77856 + timestamp: 1781203599810 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda + sha256: e755e234236bdda3d265ae82e5b0581d259a9279e3e5b31d745dc43251ad64fb + md5: 47595b9d53054907a00d95e4d47af1d6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libogg >=1.3.5,<1.4.0a0 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libflac >=1.5.0,<1.6.0a0 + size: 424563 + timestamp: 1764526740626 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda + sha256: 38f014a7129e644636e46064ecd6b1945e729c2140e21d75bb476af39e692db2 + md5: e289f3d17880e44b633ba911d57a321b + depends: + - libfreetype6 >=2.14.3 + license: GPL-2.0-only OR FTL + run_exports: {} + size: 8049 + timestamp: 1774298163029 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda + sha256: 16f020f96da79db1863fcdd8f2b8f4f7d52f177dd4c58601e38e9182e91adf1d + md5: fb16b4b69e3f1dcfe79d80db8fd0c55d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpng >=1.6.55,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - freetype >=2.14.3 + license: GPL-2.0-only OR FTL + run_exports: {} + size: 384575 + timestamp: 1774298162622 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + sha256: 8e0a3b5e41272e5678499b5dfc4cddb673f9e935de01eb0767ce857001229f46 + md5: 57736f29cc2b0ec0b6c2952d3f101b6a + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_19 + - libgomp 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 1041084 + timestamp: 1778269013026 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda + sha256: 9dcf54adfaa5e861123c2da4f2f0451a685464ea7e5a41ad91cf67b31d658d98 + md5: 331ee9b72b9dff570d56b1302c5ab37d + depends: + - libgcc 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - libgcc + size: 27694 + timestamp: 1778269016987 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda + sha256: 561a42758ef25b9ce308c4e2cf56daee4f06138385a17e29a492cd928e00be6f + md5: 42bf7eca1a951735fa06c0e3c0d5c8e6 + depends: + - libgfortran5 15.2.0 h68bc16d_19 + constrains: + - libgfortran-ng ==15.2.0=*_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 27655 + timestamp: 1778269042954 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda + sha256: 057978bb69fea29ed715a9b98adf71015c31baecc4aeb2bfc20d4fd5d83579d4 + md5: 85072b0ad177c966294f129b7c04a2d5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15.2.0 + constrains: + - libgfortran 15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 2483673 + timestamp: 1778269025089 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + sha256: ec353b3076ed8e357ed961d0e9ff6997491cade0e603de5bd18a2e301ac78ebd + md5: f25206d7322c0e9648e8b83694d143ab + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + - libglx 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: {} + size: 133469 + timestamp: 1779728207669 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_3.conda + sha256: 41d7d864ad1f199bdb06ff6cc3931455c8af62f1d2071a08c6fa08affbcb678f + md5: 63e43d278ee5084813fe3c2edf4834ce + depends: + - __glibc >=2.17,<3.0.a0 + - libgl 1.7.0 ha4b6fd6_3 + - libglx-devel 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: + weak: + - libgl >=1.7.0,<2.0a0 + size: 115664 + timestamp: 1779728218325 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.2-h0d30a3d_0.conda + sha256: 4bee10e62796f01e4fa2b5849135b1cc061337fe9cf5eb9bd79e9664922ae0e4 + md5: 889febc66cd9e4190f80ef9718fa239b + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libiconv >=1.18,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - libffi >=3.5.2,<3.6.0a0 + - pcre2 >=10.47,<10.48.0a0 + constrains: + - glib >2.66 + license: LGPL-2.1-or-later + run_exports: + weak: + - libglib >=2.88.2,<3.0a0 + size: 4754220 + timestamp: 1782463895250 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda + sha256: a0105eb88f76073bbb30169312e797ed5449ebb4e964a756104d6e54633d17ef + md5: 8422fcc9e5e172c91e99aef703b3ce65 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libopengl >=1.7.0,<2.0a0 + - libstdcxx >=13 + license: SGI-B-2.0 + run_exports: + weak: + - libglu >=9.0.3,<9.1.0a0 + size: 325262 + timestamp: 1748692137626 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + sha256: e019ebe4e3f5cdf23e2f5e58ddf7ade27988c53820115b17b98f218ebcc87748 + md5: eb83f3f8cecc3e9bff9e250817fc69b6 + depends: + - __glibc >=2.17,<3.0.a0 + license: LicenseRef-libglvnd + run_exports: {} + size: 133586 + timestamp: 1779728183422 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + sha256: 2f74713c9ca408ea84e88a30a9028153e7b553e8bb42e06139eac9a753c27da9 + md5: ec3c4350aa0261bf7f87b8ca15c8e80e + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + - xorg-libx11 >=1.8.13,<2.0a0 + license: LicenseRef-libglvnd + run_exports: {} + size: 76586 + timestamp: 1779728199059 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_3.conda + sha256: a17ae2d4cb2de04a20882ae14ec3cc1958e868a4dec81e3d7eca30115ee50e94 + md5: 16b6330783ce0d1ae8d22782173b32c9 + depends: + - __glibc >=2.17,<3.0.a0 + - libglx 1.7.0 ha4b6fd6_3 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-xorgproto + license: LicenseRef-libglvnd + run_exports: + weak: + - libglx >=1.7.0,<2.0a0 + size: 27363 + timestamp: 1779728211402 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + sha256: 5abe4ab9d93f6c9757d654f1969ae2267d4505315c1f2f8fe705fd60af084f1b + md5: faac990cb7aedc7f3a2224f2c9b0c26c + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 603817 + timestamp: 1778268942614 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.13.0-default_he001693_1000.conda + sha256: 5041d295813dfb84652557839825880aae296222ab725972285c5abe3b6e4288 + md5: c197985b58bc813d26b42881f0021c82 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libhwloc >=2.13.0,<2.13.1.0a0 + size: 2436378 + timestamp: 1770953868164 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda + sha256: 8b70955d5e9a49d08945d4f8e2eab855b2efa5fce9cb9bc5e75d86764e6f2f38 + md5: 3a9428b74c403c71048104d38437b48c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 OR BSD-3-Clause + run_exports: + weak: + - libhwy >=1.4.0,<1.5.0a0 + size: 1435782 + timestamp: 1776989559668 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f + md5: 915f5995e94f60e9a4826e0b0920ee88 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 790176 + timestamp: 1754908768807 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda + sha256: 10056646c28115b174de81a44e23e3a0a3b95b5347d2e6c45cc6d49d35294256 + md5: 6178c6f2fb254558238ef4e6c56fb782 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + run_exports: + weak: + - libjpeg-turbo >=3.1.4.1,<4.0a0 + size: 633831 + timestamp: 1775962768273 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.2-h174a0a3_1.conda + sha256: 0c8a78c6a42a6e4c6de3a5e82d692f60400d43f4cc80591745f28b37daad9c70 + md5: 850f48943d6b4589800a303f0de6a816 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libhwy >=1.4.0,<1.5.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libjxl >=0.11,<1.0a0 + size: 1846962 + timestamp: 1777065125966 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda + build_number: 8 + sha256: 168e327d737059553e15cc6ec36d76b9bbb3931c2a7721555fd68b4c9348b247 + md5: 809be8ba8712c77bc7d44c2d99390dc4 + depends: + - libblas 3.11.0 8_h4a7cf45_openblas + constrains: + - blas 2.308 openblas + - libcblas 3.11.0 8*_openblas + - liblapacke 3.11.0 8*_openblas + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - liblapack >=3.11.0,<3.12.0a0 + size: 18790 + timestamp: 1779859115086 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-8_h6ae95b6_openblas.conda + build_number: 8 + sha256: 0b982865888a73fe7c2e7d8e8ee3738ac378b83012a7da1a12264a473cb2382b + md5: da17457f689df5c0f246d2f0b3798fd2 + depends: + - libblas 3.11.0 8_h4a7cf45_openblas + - libcblas 3.11.0 8_h0358290_openblas + - liblapack 3.11.0 8_h47877c9_openblas + constrains: + - blas 2.308 openblas + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - liblapacke >=3.11.0,<3.12.0a0 + size: 18824 + timestamp: 1779859122364 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.8-hf7376ad_1.conda + sha256: e9b5f301d6b001a9b8ce782157f56b75c92c4fbc9eba95dc6345c1139251d13b + md5: 298bb2483fc7d15396147cf1c1465359 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm22 >=22.1.8,<22.2.0a0 + size: 44320272 + timestamp: 1781788728739 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d + md5: b88d90cad08e6bc8ad540cb310a761fb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 113478 + timestamp: 1775825492909 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-devel-5.8.3-hb03c661_0.conda + sha256: 7858f6a173206bc8a5bdc8e75690483bb66c0dcc3809ac1cb43c561a4723623a + md5: 55c20edec8e90c4703787acaade60808 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - liblzma 5.8.3 hb03c661_0 + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 491429 + timestamp: 1775825511214 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.10.0-nompi_hb6f1874_104.conda + sha256: 7deab19f51934a5bfdf8eec10721d91a02c6a27632471527b07ece8f0d2c7a8e + md5: e9e60f617e5545e1a7de60c2d01f8029 + depends: + - __glibc >=2.17,<3.0.a0 + - blosc >=1.21.6,<2.0a0 + - bzip2 >=1.0.8,<2.0a0 + - hdf4 >=4.2.15,<4.2.16.0a0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libaec >=1.1.5,<2.0a0 + - libcurl >=8.19.0,<9.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + - libzip >=1.11.2,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.6,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libnetcdf >=4.10.0,<4.10.1.0a0 + size: 862900 + timestamp: 1776686244733 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + sha256: 663444d77a42f2265f54fb8b48c5450bfff4388d9c0f8253dd7855f0d993153f + md5: 2a45e7f8af083626f009645a6481f12d + depends: + - __glibc >=2.17,<3.0.a0 + - c-ares >=1.34.6,<2.0a0 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.5,<4.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libnghttp2 >=1.68.1,<2.0a0 + size: 663344 + timestamp: 1773854035739 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + sha256: 927fe72b054277cde6cb82597d0fcf6baf127dcbce2e0a9d8925a68f1265eef5 + md5: d864d34357c3b65a4b731f78c0801dc4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-only + license_family: GPL + run_exports: + weak: + - libnsl >=2.0.1,<2.1.0a0 + size: 33731 + timestamp: 1750274110928 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda + sha256: 3b3f19ced060013c2dd99d9d46403be6d319d4601814c772a3472fe2955612b0 + md5: 7c7927b404672409d9917d49bff5f2d6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-or-later + run_exports: + weak: + - libntlm >=1.8,<2.0a0 + size: 33418 + timestamp: 1734670021371 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda + sha256: ffb066ddf2e76953f92e06677021c73c85536098f1c21fcd15360dbc859e22e4 + md5: 68e52064ed3897463c0e958ab5c8f91b + depends: + - libgcc >=13 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libogg >=1.3.5,<1.4.0a0 + size: 218500 + timestamp: 1745825989535 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda + sha256: 3d9aa85648e5e18a6d66db98b8c4317cc426721ad7a220aa86330d1ccedc8903 + md5: 2d3278b721e40468295ca755c3b84070 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + constrains: + - openblas >=0.3.33,<0.3.34.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libopenblas >=0.3.33,<1.0a0 + size: 5931919 + timestamp: 1776993658641 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopencv-5.0.0-headless_hf42b598_2.conda + sha256: 3375ee4bfe45b1098fbb4bea435785754f6ba23c1bf499702ae1cd3fbda11bd8 + md5: 56ba00768d54e6417b3520c1b9c22d37 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + - ffmpeg >=8.1.2,<9.0a0 + - harfbuzz >=14.2.1 + - hdf5 >=1.14.6,<1.14.7.0a0 + - imath >=3.2.2,<3.2.3.0a0 + - libavif16 >=1.4.2,<2.0a0 + - libcblas >=3.9.0,<4.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libjxl >=0.11,<1.0a0 + - liblapack >=3.9.0,<4.0a0 + - liblapacke >=3.9.0,<4.0a0 + - libopenvino >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-batch-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-hetero-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-cpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-gpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-npu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-ir-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-onnx-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-paddle-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-pytorch-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-lite-frontend >=2026.2.1,<2026.2.2.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + - libtiff >=4.7.1,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openexr >=3.4.13,<3.5.0a0 + - openjpeg >=2.5.4,<3.0a0 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - libopencv >=5.0.0,<5.0.1.0a0 + size: 34970016 + timestamp: 1782350338483 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_3.conda + sha256: 90777039b48529283df5f16383fc399866024257a8bd93de583f4730db1ab30a + md5: c2bd8055a2e2dce7a7f32cfd02101fb6 + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: {} + size: 51767 + timestamp: 1779728204026 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-devel-1.7.0-ha4b6fd6_3.conda + sha256: 6958088c10e21bae95a3db5ff170b3e32a8b439736c1add7c037c312d4bd0b87 + md5: 50c6d76c6c5ec179ad463837f0f12a17 + depends: + - __glibc >=2.17,<3.0.a0 + - libopengl 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + run_exports: + weak: + - libopengl >=1.7.0,<2.0a0 + size: 16667 + timestamp: 1779728214747 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-2026.2.1-h1f0fae8_1.conda + sha256: 7941fb9ba8c3a5a0a2401dc4120e8fcb561b96d928c43374eb93f545019a2858 + md5: ea41753f926f73966629d81fdf20ec6f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino >=2026.2.1,<2026.2.2.0a0 + size: 6823841 + timestamp: 1782219077259 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2026.2.1-h7e124b3_1.conda + sha256: 3ac14d36fa890840ae8474b8a9f0a094b8542fd8fbc409faf3d465c68f20aff0 + md5: 5698a64698e14e8a2e9e16f8f0de0e2e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 114628 + timestamp: 1782219097820 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2026.2.1-h7e124b3_1.conda + sha256: 499a472fc7b598ad3753b8f2afe60eb5a277d48eca9362e8aca094b2862587a7 + md5: 2ce088ef09292930d4cb3262ce7e144d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 250912 + timestamp: 1782219111223 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2026.2.1-hd41364c_1.conda + sha256: bec24379598a4405de171ad151945e79743c6bd049aceabf190b753c3f7a11da + md5: 02e71250f7ca786c4b183d0a39ef63ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 215488 + timestamp: 1782219123433 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2026.2.1-h1f0fae8_1.conda + sha256: eecc040a7838752a2dff9b4435a4c59bbc67b83e0c880457935b968206cb20b5 + md5: 7288f979a74cfe3fd4b32d8a0dc7baa4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 13637410 + timestamp: 1782219135415 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2026.2.1-h1f0fae8_1.conda + sha256: a47442ce578b022e19a306f963536a108cc79385f4e09d57a14a849b6a864604 + md5: c0a258b12f0c18c476b8344dbd6db8d5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - ocl-icd >=2.3.4,<3.0a0 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 12367381 + timestamp: 1782219178219 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-npu-plugin-2026.2.1-h1f0fae8_1.conda + sha256: 45a91feb68ccce90ad0fa86520572233ca20be56deae0c920f86133d020ad1e8 + md5: c214b149e108e92672e0ee097ebe16f7 + depends: + - __glibc >=2.17,<3.0.a0 + - level-zero >=1.29.0,<2.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 2630818 + timestamp: 1782219217519 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-ir-frontend-2026.2.1-hd41364c_1.conda + sha256: ebeba9a3ac9505ee69b556865b7d1b9fbbad01ca1ebe6a4249ff62c3dc677b47 + md5: 2d946aebcf06e9ba438880987050e975 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-ir-frontend >=2026.2.1,<2026.2.2.0a0 + size: 201061 + timestamp: 1782219232657 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2026.2.1-h607c73d_1.conda + sha256: 7b105c0102356352d6d9518a112ff6343dab6b8f32c837809117cd26cbf006df + md5: 3bd3599825189418ea14b2c9da3a6d87 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-onnx-frontend >=2026.2.1,<2026.2.2.0a0 + size: 1944558 + timestamp: 1782219246849 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2026.2.1-h607c73d_1.conda + sha256: af45c03d41ebe0b48c28b68be31ee919cb801ac5077164808a66db515ad6a316 + md5: 91e198085bff9d8fa02d4d947f026ba8 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-paddle-frontend >=2026.2.1,<2026.2.2.0a0 + size: 690240 + timestamp: 1782219261154 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2026.2.1-hecca717_1.conda + sha256: e6353874a36143ffb7db7ec2c3767fd5e3434a8eeff41a569bc46e68259f668f + md5: 152d6694f1d05b53319b8376cdd811e4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-pytorch-frontend >=2026.2.1,<2026.2.2.0a0 + size: 1226625 + timestamp: 1782219274006 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2026.2.1-h21c0c73_1.conda + sha256: cffe112815b8eb57528fdfdf8b39f6a0915884291147dab5bc2066d2bf123031 + md5: 89d2455ec2f065786856b0cd2ac1c0c6 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + - snappy >=1.2.2,<1.3.0a0 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-tensorflow-frontend >=2026.2.1,<2026.2.2.0a0 + size: 1284650 + timestamp: 1782219287644 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2026.2.1-hecca717_1.conda + sha256: 142e7b24173ca8c32dbdb29c60f33a56ffb21a4ed733c9d6ab160c3a213ff52e + md5: c1a50f20847df0a8cb462138153ab46f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libopenvino-tensorflow-lite-frontend >=2026.2.1,<2026.2.2.0a0 + size: 501906 + timestamp: 1782219300706 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda + sha256: f1061a26213b9653bbb8372bfa3f291787ca091a9a3060a10df4d5297aad74fd + md5: 2446ac1fe030c2aa6141386c1f5a6aed + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libopus >=1.6.1,<2.0a0 + size: 324993 + timestamp: 1768497114401 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_0.conda + sha256: f41721636a7c2e51bc2c642e1127955ab9c81145470714fdaac44d4d09e4af41 + md5: 33082e13b4769b48cfeb648e15bfe3fc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libpciaccess >=0.19,<0.20.0a0 + size: 29147 + timestamp: 1773533027610 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libplacebo-7.360.1-h9eeb4b2_0.conda + sha256: 26cbbd3d7b91801826c779c3f7e87d071856d5cbe3d55b22777ca0d984fb02ed + md5: e6324dfe6c02e0736bb9235f8ef3c8a6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libdovi >=3.3.2,<4.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - lcms2 >=2.19,<3.0a0 + - shaderc >=2026.2,<2026.3.0a0 + license: LGPL-2.1-or-later + run_exports: + weak: + - libplacebo >=7.360.1,<7.361.0a0 + size: 549348 + timestamp: 1777835950707 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda + sha256: 377cfe037f3eeb3b1bf3ad333f724a64d32f315ee1958581fc671891d63d3f89 + md5: eba48a68a1a2b9d3c0d9511548db85db + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: zlib-acknowledgement + run_exports: + weak: + - libpng >=1.6.58,<1.7.0a0 + size: 317729 + timestamp: 1776315175087 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpq-18.4-hd5a49e9_1.conda + sha256: f7232cb79a31f5a5bcd499aea930b469cde8b96d26db9541022493fd274d2a6e + md5: 6c9103e7ea739a3bb3505da49a4708c1 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - openldap >=2.6.13,<2.7.0a0 + - openssl >=3.5.7,<4.0a0 + license: PostgreSQL + run_exports: + weak: + - libpq >=18.4,<19.0a0 + size: 2709008 + timestamp: 1782580447454 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-7.35.1-h3a69515_1.conda + sha256: a14fc571ea573d733d2c18abb52123c09d56610dbf29d03cc85cf1470f5cc8ae + md5: c80393b49f041180405a587e5ac59b49 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libprotobuf >=7.35.1,<7.35.2.0a0 + size: 3942143 + timestamp: 1781325648920 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libraqm-0.10.5-h75b3fb1_0.conda + sha256: 36870c7e6362386c687f2f40d98de28f53ef84582ff65792f2f53981ede82681 + md5: 6855be9eb1d891cd5afb5eb90501c74c + depends: + - libgcc >=14 + - __glibc >=2.28,<3.0.a0 + - fribidi >=1.0.16,<2.0a0 + - harfbuzz >=14.2.1 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + license: MIT + license_family: MIT + run_exports: + weak: + - libraqm >=0.10.5,<0.11.0a0 + size: 29594 + timestamp: 1780835041392 +- conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.3-h4c96295_0.conda + sha256: 5571bd8239d71961d4e3ce972f865b3ea95a91ce0b53d5749fe2dd24254ddbda + md5: 492c8d9b1c564c2e948b6cb4ba0f8261 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.0,<3.0a0 + - fonts-conda-ecosystem + - gdk-pixbuf >=2.44.6,<3.0a0 + - harfbuzz >=14.2.0 + - libgcc >=14 + - libglib >=2.88.1,<3.0a0 + - libxml2-16 >=2.14.6 + - pango >=1.56.4,<2.0a0 + constrains: + - __glibc >=2.17 + license: LGPL-2.1-or-later + run_exports: + weak: + - librsvg >=2.62.3,<3.0a0 + size: 3476570 + timestamp: 1780450632624 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.3.0-h8f1669f_19.conda + sha256: 8766de5423b0a510e2b1bdd1963d0554bdad2119f3e31d8fbd4189af434235ca + md5: 007796e5a595bbc7df4a5e1580d72e1a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14.3.0 + - libstdcxx >=14.3.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + weak: + - libsanitizer 14.3.0 + size: 7947790 + timestamp: 1778268494844 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda + sha256: 57cb5f92110324c04498b96563211a1bca6a74b2918b1e8df578bfed03cc32e4 + md5: 067590f061c9f6ea7e61e3b2112ed6b3 + depends: + - __glibc >=2.17,<3.0.a0 + - lame >=3.100,<3.101.0a0 + - libflac >=1.5.0,<1.6.0a0 + - libgcc >=14 + - libogg >=1.3.5,<1.4.0a0 + - libopus >=1.5.2,<2.0a0 + - libstdcxx >=14 + - libvorbis >=1.3.7,<1.4.0a0 + - mpg123 >=1.32.9,<1.33.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + run_exports: + weak: + - libsndfile >=1.2.2,<1.3.0a0 + size: 355619 + timestamp: 1765181778282 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda + sha256: 365376f4815e5e80def2b3462a2419708b7c292da0da85278386c2618621fff4 + md5: 4aed8e657e9ff156bdbe849b4df44389 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.3,<4.0a0 + size: 962119 + timestamp: 1782519076616 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + sha256: fa39bfd69228a13e553bd24601332b7cfeb30ca11a3ca50bb028108fe90a7661 + md5: eecce068c7e4eddeb169591baac20ac4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libssh2 >=1.11.1,<2.0a0 + size: 304790 + timestamp: 1745608545575 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + sha256: dff1058c76ec6b8759e41cefa2508162d00e4a5e6721aa68ec3fd10094e702dc + md5: 5794b3bdc38177caf969dabd3af08549 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_19 + constrains: + - libstdcxx-ng ==15.2.0=*_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 5852044 + timestamp: 1778269036376 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda + sha256: 0672b6b6e1791c92e8eccad58081a99d614fcf82bca5841f9dfa3c3e658f83b9 + md5: e5ce228e579726c07255dbf90dc62101 + depends: + - libstdcxx 15.2.0 h934c35e_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - libstdcxx + size: 27776 + timestamp: 1778269074600 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-h084b8d7_1.conda + sha256: 2293884d59cf0436c37fc0a4bad71011a8de2a6913610d1c701a7703377c1f75 + md5: ea0da9c20bbb221b530810c3c68bbe62 + depends: + - __glibc >=2.17,<3.0.a0 + - libcap >=2.78,<2.79.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + run_exports: {} + size: 493022 + timestamp: 1780084748140 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libtheora-1.1.1-h4ab18f5_1006.conda + sha256: 50c8cd416ac8425e415264de167b41ae8442de22a91098dfdd993ddbf9f13067 + md5: 553281a034e9cf8693c9df49f6c78ea1 + depends: + - libgcc-ng >=12 + - libogg 1.3.* + - libogg >=1.3.5,<1.4.0a0 + - libvorbis 1.3.* + - libvorbis >=1.3.7,<1.4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libtheora >=1.1.1,<1.2.0a0 + size: 328924 + timestamp: 1719667859099 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda + sha256: e5f8c38625aa6d567809733ae04bb71c161a42e44a9fa8227abe61fa5c60ebe0 + md5: cd5a90476766d53e901500df9215e927 + depends: + - __glibc >=2.17,<3.0.a0 + - lerc >=4.0.0,<5.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libgcc >=14 + - libjpeg-turbo >=3.1.0,<4.0a0 + - liblzma >=5.8.1,<6.0a0 + - libstdcxx >=14 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + run_exports: + weak: + - libtiff >=4.7.1,<4.8.0a0 + size: 435273 + timestamp: 1762022005702 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-h084b8d7_1.conda + sha256: 287d05680e49eea51b8145fbf34bc213c0618b04f32e450e9da5d715e5134e38 + md5: 89e5671a076d99516a6acd72a35b1640 + depends: + - __glibc >=2.17,<3.0.a0 + - libcap >=2.78,<2.79.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + run_exports: {} + size: 145969 + timestamp: 1780084753104 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libunwind-1.8.3-h65a8314_0.conda + sha256: 71c8b9d5c72473752a0bb6e91b01dd209a03916cb71f36cc6a564e3a2a132d7a + md5: e179a69edd30d75c0144d7a380b88f28 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libunwind >=1.8.3,<1.9.0a0 + size: 75995 + timestamp: 1757032240102 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liburing-2.14-hb700be7_0.conda + sha256: 3d17b7aa90610afc65356e9e6149aeac0b2df19deda73a51f0a09cf04fd89286 + md5: 56f65185b520e016d29d01657ac02c0d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - liburing >=2.14,<2.15.0a0 + size: 154203 + timestamp: 1770566529700 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda + sha256: 89c84f5b26028a9d0f5c4014330703e7dff73ba0c98f90103e9cef6b43a5323c + md5: d17e3fb595a9f24fa9e149239a33475d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libudev1 >=257.4 + license: LGPL-2.1-or-later + run_exports: + weak: + - libusb >=1.0.29,<2.0a0 + size: 89551 + timestamp: 1748856210075 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f + md5: 01bb81d12c957de066ea7362007df642 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libuuid >=2.42.2,<3.0a0 + size: 40017 + timestamp: 1781625522462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda + sha256: e28e4519223f78b3163599ca89c3f2d80bfb53e907e7fc74e806e60d1efa578b + md5: 4e33d49bf4fc853855a3b00643aa5484 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libuv >=1.52.1,<2.0a0 + size: 419935 + timestamp: 1779396012261 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libva-2.23.0-he1eb515_0.conda + sha256: 255c7d00b54e26f19fad9340db080716bced1d8539606e2b8396c57abd40007c + md5: 25813fe38b3e541fc40007592f12bae5 + depends: + - __glibc >=2.17,<3.0.a0 + - libdrm >=2.4.125,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglx >=1.7.0,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - wayland >=1.24.0,<2.0a0 + - wayland-protocols + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libva >=2.23.0,<3.0a0 + size: 221308 + timestamp: 1765652453244 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda + sha256: ca494c99c7e5ecc1b4cd2f72b5584cef3d4ce631d23511184411abcbb90a21a5 + md5: b4ecbefe517ed0157c37f8182768271c + depends: + - libogg + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - libogg >=1.3.5,<1.4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libvorbis >=1.3.7,<1.4.0a0 + size: 285894 + timestamp: 1753879378005 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvpl-2.16.0-h54a6638_0.conda + sha256: 38850657dd6835613ef16b34895a54bea98bc7639db6a649c886b331635714fc + md5: 9f6b0090c3902b2c763a16f7dace7b6e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - intel-media-driver >=26.1.2,<26.2.0a0 + - libva >=2.23.0,<3.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libvpl >=2.16.0,<2.17.0a0 + size: 287992 + timestamp: 1772980546550 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvpx-1.15.2-hecca717_0.conda + sha256: 8e1119977f235b488ab32d540c018d3fd1eccefc3dd3859921a0ff555d8c10d2 + md5: 10f5008f1c89a40b09711b5a9cdbd229 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libvpx >=1.15.2,<1.16.0a0 + size: 1070048 + timestamp: 1762010217363 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.341.0-h5279c79_0.conda + sha256: a68280d57dfd29e3d53400409a39d67c4b9515097eba733aa6fe00c880620e2b + md5: 31ad065eda3c2d88f8215b1289df9c89 + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + constrains: + - libvulkan-headers 1.4.341.0.* + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - libvulkan-loader >=1.4.341.0,<2.0a0 + size: 199795 + timestamp: 1770077125520 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda + sha256: 3aed21ab28eddffdaf7f804f49be7a7d701e8f0e46c856d801270b470820a37b + md5: aea31d2e5b1091feca96fcfe945c3cf9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libwebp-base >=1.6.0,<2.0a0 + size: 429011 + timestamp: 1752159441324 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda + sha256: 666c0c431b23c6cec6e492840b176dde533d48b7e6fb8883f5071223433776aa + md5: 92ed62436b625154323d40d5f2f11dd7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - pthread-stubs + - xorg-libxau >=1.0.11,<2.0a0 + - xorg-libxdmcp + license: MIT + license_family: MIT + run_exports: + weak: + - libxcb >=1.17.0,<2.0a0 + size: 395888 + timestamp: 1727278577118 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c + md5: 5aa797f8787fe7a17d1b0821485b5adc + depends: + - libgcc-ng >=12 + license: LGPL-2.1-or-later + run_exports: + weak: + - libxcrypt >=4.4.36 + size: 100393 + timestamp: 1702724383534 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + sha256: 046f2ff4acebd8729fac03e99c8c307dfb48b6a32894ba8c11576e78f6e76e43 + md5: dc8b067e22b414172bedd8e3f03f3c95 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - xkeyboard-config + - xorg-libxau >=1.0.12,<2.0a0 + license: MIT/X11 Derivative + license_family: MIT + run_exports: + weak: + - libxkbcommon >=1.13.2,<2.0a0 + size: 851166 + timestamp: 1780213397575 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbfile-1.2.0-hb03c661_0.conda + sha256: 49a0a85e595d1d6c7f355e13793fbcb650d42182f1051f81b15ae754f7c5692a + md5: f330a1b107cb5861f26dbb72a26f33e8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.13,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxkbfile >=1.2.0,<2.0a0 + size: 87066 + timestamp: 1778169480942 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + sha256: 3d44f737c5ae52d5af32682cc1530df433f401f8e58a7533926536244127572a + md5: e79d2c2f24b027aa8d5ab1b1ba3061e7 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.3 + license: MIT + license_family: MIT + run_exports: {} + size: 559775 + timestamp: 1776376739004 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda + sha256: 3bc5551720c58591f6ea1146f7d1539c734ed1c40e7b9f5cb8cb7e900c509aba + md5: 995d8c8bad2a3cc8db14675a153dec2b + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 hca6bf5a_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.3 + size: 46810 + timestamp: 1776376751152 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda + sha256: 991e7348b0f650d495fb6d8aa9f8c727bdf52dabf5853c0cc671439b160dce48 + md5: a7b27c075c9b7f459f1c022090697cba + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.3.2,<4.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libzip >=1.11.2,<2.0a0 + size: 109043 + timestamp: 1730442108429 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 + md5: d87ff7921124eccd67248aa483c23fec + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 63629 + timestamp: 1774072609062 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + sha256: 47326f811392a5fd3055f0f773036c392d26fdb32e4d8e7a8197eed951489346 + md5: 9de5350a85c4a20c685259b889aa6393 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - lz4-c >=1.10.0,<1.11.0a0 + size: 167055 + timestamp: 1733741040117 +- conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.0-py312h1c5ec97_0.conda + sha256: 4d5db0491814ce2e70053ae5ac9ecd0a4f7103adb6df0e6eb0dcb7638145e65b + md5: 847125fead148cb26f52f8c3413cea12 + depends: + - __glibc >=2.17,<3.0.a0 + - contourpy >=1.0.1 + - cycler >=0.10 + - fonttools >=4.22.0 + - freetype + - kiwisolver >=1.3.1 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libraqm >=0.10.5,<0.11.0a0 + - libstdcxx >=14 + - numpy >=1.23 + - numpy >=1.23,<3 + - packaging >=20.0 + - pillow >=8 + - pyparsing >=2.3.1 + - python >=3.12,<3.13.0a0 + - python-dateutil >=2.7 + - python_abi 3.12.* *_cp312 + - qhull >=2020.2,<2020.3.0a0 + - tk >=8.6.13,<8.7.0a0 + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 9022139 + timestamp: 1781626880429 +- conda: https://conda.anaconda.org/conda-forge/linux-64/mesalib-26.0.3-h8cca3c9_0.conda + sha256: 3e6385e04e5a8f62599a5eb72b9a8c65ac143f0621d57f24688f103276d686eb + md5: 823e4ae1d60e97845773f7358585fc56 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - xorg-libxshmfence >=1.3.3,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + - libexpat >=2.7.4,<3.0a0 + - spirv-tools >=2026,<2027.0a0 + - libllvm22 >=22.1.1,<22.2.0a0 + - libxcb >=1.17.0,<2.0a0 + - libdrm >=2.4.125,<2.5.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - mesalib >=26.0.3,<26.1.0a0 + size: 2964576 + timestamp: 1773867966762 +- conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.32.9-hc50e24c_0.conda + sha256: 39c4700fb3fbe403a77d8cc27352fa72ba744db487559d5d44bf8411bb4ea200 + md5: c7f302fd11eeb0987a6a5e1f3aed6a21 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: LGPL-2.1-only + license_family: LGPL + run_exports: + weak: + - mpg123 >=1.32.9,<1.33.0a0 + size: 491140 + timestamp: 1730581373280 +- conda: https://conda.anaconda.org/conda-forge/linux-64/msgpack-python-1.2.1-py312h0a2e395_1.conda + sha256: c9764c77dd7f9c581c1d8a24c3024edf777cacfb5a4180de5badc6638dc8590f + md5: a142257c1b69e37cfefc66dc228a4d93 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 112800 + timestamp: 1782460774570 +- conda: https://conda.anaconda.org/conda-forge/linux-64/multidict-6.7.1-py312h8a5da7c_0.conda + sha256: 0da7e7f4e69bfd6c98eff92523e93a0eceeaec1c6d503d4a4cd0af816c3fe3dc + md5: 17c77acc59407701b54404cfd3639cac + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 100056 + timestamp: 1771611023053 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nanoflann-1.6.1-hff21bea_0.conda + sha256: 0141796f802039a40d3e2bce0d1183040f8cd2c53453455cb1401df1eb01d478 + md5: acccd21b34ac988d1b26d15c53b28f65 + license: BSD + run_exports: {} + size: 25915 + timestamp: 1728332440211 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 + md5: fc21868a1a5aacc937e7a18747acb8a5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 918956 + timestamp: 1777422145199 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda + sha256: fd2cbd8dfc006c72f45843672664a8e4b99b2f8137654eaae8c3d46dca776f63 + md5: 16c2a0e9c4a166e53632cfca4f68d020 + constrains: + - nlohmann_json-abi ==3.12.0 + license: MIT + license_family: MIT + run_exports: {} + size: 136216 + timestamp: 1758194284857 +- conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.0-py312h33ff503_0.conda + sha256: c8d5f70715fc6cd3dcd16fdd11b51879ed4484963f066b33fbaf20c4ffb153af + md5: 24f70d3db040fc69ee72cc38e55bc8e3 + depends: + - python + - libgcc >=14 + - libstdcxx >=14 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.12.* *_cp312 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - libblas >=3.9.0,<4.0a0 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - numpy >=1.25,<3 + size: 8911732 + timestamp: 1782112536981 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.4-hb03c661_1.conda + sha256: 75f3bf733523a338f73d6c276c4a26634877cd970edb558f2769d9fa52b100a9 + md5: c2871ba95727fd1382c05db66048b64c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - opencl-headers >=2025.6.13 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - ocl-icd >=2.3.4,<3.0a0 + size: 109598 + timestamp: 1780362789611 +- conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda + sha256: 8de2f0cd8a659b01abf86e7fbb8cea4f28ada62fd288429a2bbc040db1b98dd0 + md5: c930c8052d780caa41216af7de472226 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 55754 + timestamp: 1773844383536 +- conda: https://conda.anaconda.org/conda-forge/linux-64/opencv-5.0.0-headless_h47a1348_2.conda + noarch: python + sha256: bf57666d7efc3e967a3ec45af85fc72e2ffd829ac1724e7140a9f23806a55bc4 + md5: 6312b21a8d88670aad0ffb7240108ad2 + depends: + - libopencv 5.0.0 headless_hf42b598_2 + - py-opencv 5.0.0 headless_h75b7ce4_2 + license: Apache-2.0 + license_family: Apache + run_exports: {} + size: 48871 + timestamp: 1782350453544 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.4.13-hf734b31_1.conda + sha256: 17e3d2769dfc018748f0c09757b95744a5e942f74325b605d06303a69ee7955d + md5: 9db8aff65eaea058afc9fe93569b4b14 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - imath >=3.2.2,<3.2.3.0a0 + - openjph >=0.30.1,<0.31.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - openexr >=3.4.13,<3.5.0a0 + size: 1223929 + timestamp: 1782198396418 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-h65dd3cf_1.conda + sha256: 5317c5c23762f3fe1c8510565a2bb94c645e1470ff73b386315656404f7eb58a + md5: 69894a95220a17a66272daa701c387bc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - openh264 >=2.6.0,<2.6.1.0a0 + size: 726478 + timestamp: 1782685945856 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + sha256: 3900f9f2dbbf4129cf3ad6acf4e4b6f7101390b53843591c53b00f034343bc4d + md5: 11b3379b191f63139e29c0d19dee24cd + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpng >=1.6.50,<1.7.0a0 + - libstdcxx >=14 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - openjpeg >=2.5.4,<3.0a0 + size: 355400 + timestamp: 1758489294972 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.30.1-h8d634f6_0.conda + sha256: e405a62cc16604c4274d1d64387fa62ba5466cc65fa087ae45451d0150f4b698 + md5: 6143d4af035262f7e1b954e1cba9156d + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - libtiff >=4.7.1,<4.8.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - openjph >=0.30.1,<0.31.0a0 + size: 294315 + timestamp: 1782091151122 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda + sha256: 21c4f6c7f41dc9bec2ea2f9c80440d9a4d45a6f2ac13243e658f10dcf1044146 + md5: 680608784722880fbfe1745067570b00 + depends: + - __glibc >=2.17,<3.0.a0 + - cyrus-sasl >=2.1.28,<3.0a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - libstdcxx >=14 + - openssl >=3.5.6,<4.0a0 + license: OLDAP-2.8 + license_family: BSD + run_exports: + weak: + - openldap >=2.6.13,<2.7.0a0 + size: 786149 + timestamp: 1775741359582 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + sha256: d48f5c22b9897c01e4dff3680f1f57ceb02711ab9c62f74339b080419dfad34b + md5: 79dd2074b5cd5c5c6b2930514a11e22d + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3159683 + timestamp: 1781069855778 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hda50119_1.conda + sha256: 315b52bfa6d1a820f4806f6490d472581438a28e21df175290477caec18972b0 + md5: d53ffc0edc8eabf4253508008493c5bc + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.17.1,<3.0a0 + - fonts-conda-ecosystem + - fribidi >=1.0.16,<2.0a0 + - harfbuzz >=13.2.1 + - libexpat >=2.7.4,<3.0a0 + - libfreetype >=2.14.2 + - libfreetype6 >=2.14.2 + - libgcc >=14 + - libglib >=2.86.4,<3.0a0 + - libpng >=1.6.55,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + license: LGPL-2.1-or-later + run_exports: + weak: + - pango >=1.56.4,<2.0a0 + size: 458036 + timestamp: 1774281947855 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pcl-1.15.1-h1259f1f_14.conda + sha256: 9c890fbfb48000ec404540872a5e9c847104fd0f72cc8cd6efbc6aa1bfb94108 + md5: e147c64c0a4f74740eb23b5d9cb5731a + depends: + - __glibc >=2.17,<3.0.a0 + - eigen + - eigen-abi >=5.0.1.80,<5.0.1.81.0a0 + - flann >=1.9.2,<1.9.3.0a0 + - glew >=2.3.0,<2.4.0a0 + - libboost >=1.90.0,<1.91.0a0 + - libboost-devel + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libstdcxx >=14 + - nanoflann + - qhull >=2020.2,<2020.3.0a0 + - qt6-main >=6.11.1,<7.0a0 + - vtk + - vtk-base >=9.6.2,<9.6.3.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - pcl >=1.15.1,<1.15.2.0a0 + size: 18086219 + timestamp: 1781232903970 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + sha256: 5e6f7d161356fefd981948bea5139c5aa0436767751a6930cb1ca801ebb113ff + md5: 7a3bff861a6583f1889021facefc08b1 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - pcre2 >=10.47,<10.48.0a0 + size: 1222481 + timestamp: 1763655398280 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py312h50c33e8_0.conda + sha256: fa291f8915114733dc1df9f1627b8c63c517217c1eee1a6ede2ceb5e368cf27a + md5: 9e5609720e31213d4f39afe377f6217e + depends: + - python + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - lcms2 >=2.18,<3.0a0 + - libxcb >=1.17.0,<2.0a0 + - libjpeg-turbo >=3.1.2,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - openjpeg >=2.5.4,<3.0a0 + - python_abi 3.12.* *_cp312 + - tk >=8.6.13,<8.7.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - zlib-ng >=2.3.3,<2.4.0a0 + license: HPND + run_exports: {} + size: 1039561 + timestamp: 1775060059882 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda + sha256: 43d37bc9ca3b257c5dd7bf76a8426addbdec381f6786ff441dc90b1a49143b6a + md5: c01af13bdc553d1a8fbfff6e8db075f0 + depends: + - libgcc >=14 + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + run_exports: + weak: + - pixman >=0.46.4,<1.0a0 + size: 450960 + timestamp: 1754665235234 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda + sha256: c9601efb1af5391317e04eca77c6fe4d716bf1ca1ad8da2a05d15cb7c28d7d4e + md5: 1bee70681f504ea424fb07cdb090c001 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + license: GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 115175 + timestamp: 1720805894943 +- conda: https://conda.anaconda.org/conda-forge/linux-64/proj-9.8.1-he0df7b0_0.conda + sha256: dff6f355025b9a510d9093e29fd970fa1091e758b848c9dec814d96ae63a09ba + md5: b23619e5e9009eaa070ead0342034027 + depends: + - sqlite + - libtiff + - libcurl + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libtiff >=4.7.1,<4.8.0a0 + - libsqlite >=3.53.0,<4.0a0 + - libcurl >=8.19.0,<9.0a0 + constrains: + - proj4 ==999999999999 + license: MIT + license_family: MIT + run_exports: + weak: + - proj >=9.8.1,<9.9.0a0 + size: 3652144 + timestamp: 1775840249166 +- conda: https://conda.anaconda.org/conda-forge/linux-64/propcache-0.5.2-py312h8a5da7c_0.conda + sha256: c9138bbb53d4bac010526a8deace8cf764aac13fad5280d0a71556bad6c04d29 + md5: d681d6ad9fa2ca3c8cacb7f3b23d54f3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 51586 + timestamp: 1780037816755 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda + sha256: 9c88f8c64590e9567c6c80823f0328e58d3b1efb0e1c539c0315ceca764e0973 + md5: b3c17d95b5a10c6e64a21fa17573e70e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + run_exports: {} + size: 8252 + timestamp: 1726802366959 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda + sha256: 23c98a5000356e173568dc5c5770b53393879f946f3ace716bbdefac2a8b23d2 + md5: b11a4c6bf6f6f44e5e143f759ffa2087 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: MIT + license_family: MIT + run_exports: + weak: + - pugixml >=1.15,<1.16.0a0 + size: 118488 + timestamp: 1736601364156 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda + sha256: 0a0858c59805d627d02bdceee965dd84fde0aceab03a2f984325eec08d822096 + md5: b8ea447fdf62e3597cb8d2fae4eb1a90 + depends: + - __glibc >=2.17,<3.0.a0 + - dbus >=1.16.2,<2.0a0 + - libgcc >=14 + - libglib >=2.86.1,<3.0a0 + - libiconv >=1.18,<2.0a0 + - libsndfile >=1.2.2,<1.3.0a0 + - libsystemd0 >=257.10 + - libxcb >=1.17.0,<2.0a0 + constrains: + - pulseaudio 17.0 *_3 + license: LGPL-2.1-or-later + license_family: LGPL + run_exports: + weak: + - pulseaudio-client >=17.0,<17.1.0a0 + size: 750785 + timestamp: 1763148198088 +- conda: https://conda.anaconda.org/conda-forge/linux-64/py-opencv-5.0.0-headless_h75b7ce4_2.conda + noarch: python + sha256: b952865d82e01faa0671c10416c52edd82e1c44352674218c0e0798260eb22e1 + md5: 3022e53aa371af2fd15007ce329fc325 + depends: + - _python_abi3_support 1.* + - cpython >=3.10 + - libopencv 5.0.0 headless_hf42b598_2 + - numpy >=1.21,<3 + - python >=3.10 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - py-opencv >=5.0.0,<6.0a0 + size: 3837601 + timestamp: 1782350448196 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda + sha256: a44655c1c3e1d43ed8704890a91e12afd68130414ea2c0872e154e5633a13d7e + md5: 7eccb41177e15cc672e1babe9056018e + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.7.4,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libuuid >=2.41.3,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.5,<4.0a0 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + constrains: + - python_abi 3.12.* *_cp312 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.12.* *_cp312 + noarch: + - python + size: 31608571 + timestamp: 1772730708989 +- conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda + sha256: 776363493bad83308ba30bcb88c2552632581b143e8ee25b1982c8c743e73abc + md5: 353823361b1d27eb3960efb076dfcaf6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: LicenseRef-Qhull + run_exports: + weak: + - qhull >=2020.2,<2020.3.0a0 + size: 552937 + timestamp: 1720813982144 +- conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.11.1-pl5321h16c4a6b_1.conda + sha256: aefbc43bde188ff4027d480da99c7fa9e8e6341e9762e065190239cb9b99bb1c + md5: 331d660aef48fec733a878dd1f8f4206 + depends: + - libxcb + - xcb-util + - xcb-util-wm + - xcb-util-keysyms + - xcb-util-image + - xcb-util-renderutil + - xcb-util-cursor + - libgl-devel + - libegl-devel + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - xcb-util >=0.4.1,<0.5.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - pcre2 >=10.47,<10.48.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - xorg-libxxf86vm >=1.1.7,<2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libpq >=18.4,<19.0a0 + - xorg-libice >=1.1.2,<2.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - wayland >=1.25.0,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xcb-util-keysyms >=0.4.1,<0.5.0a0 + - libpng >=1.6.58,<1.7.0a0 + - harfbuzz >=14.2.1 + - xcb-util-cursor >=0.1.6,<0.2.0a0 + - xorg-libxcursor >=1.2.3,<2.0a0 + - libcups >=2.3.3,<2.4.0a0 + - libxcb >=1.17.0,<2.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libdrm >=2.4.127,<2.5.0a0 + - xorg-libxcomposite >=0.4.7,<1.0a0 + - xcb-util-image >=0.4.0,<0.5.0a0 + - xcb-util-wm >=0.4.2,<0.5.0a0 + - zstd >=1.5.7,<1.6.0a0 + - krb5 >=1.22.2,<1.23.0a0 + - xcb-util-renderutil >=0.3.10,<0.4.0a0 + - icu >=78.3,<79.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - xorg-libsm >=1.2.6,<2.0a0 + - alsa-lib >=1.2.16,<1.3.0a0 + - openssl >=3.5.6,<4.0a0 + - libglib >=2.88.1,<3.0a0 + - libgl >=1.7.0,<2.0a0 + - libxkbcommon >=1.13.2,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - double-conversion >=3.4.0,<3.5.0a0 + - dbus >=1.16.2,<2.0a0 + - xorg-libxtst >=1.2.5,<2.0a0 + - libegl >=1.7.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + constrains: + - qt ==6.11.1 + license: LGPL-3.0-only + license_family: LGPL + run_exports: + weak: + - qt6-main >=6.11.1,<7.0a0 + size: 60185421 + timestamp: 1780593127053 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda + sha256: cf550bbc8e5ebedb6dba9ccaead3e07bd1cb86b183644a4c853e06e4b3ad5ac7 + md5: d83958768626b3c8471ce032e28afcd3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - rav1e >=0.8.1,<0.9.0a0 + size: 5595970 + timestamp: 1772540833621 +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rhash-1.4.6-hb9d3cd8_1.conda + sha256: d5c73079c1dd2c2a313c3bfd81c73dbd066b7eb08d213778c8bff520091ae894 + md5: c1c9b02933fdb2cfb791d936c20e887e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + run_exports: + weak: + - rhash >=1.4.6,<2.0a0 + size: 193775 + timestamp: 1748644872902 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda + sha256: 987ad072939fdd51c92ea8d3544b286bb240aefda329f9b03a51d9b7e777f9de + md5: cdd138897d94dc07d99afe7113a07bec + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libgl >=1.7.0,<2.0a0 + - sdl3 >=3.2.22,<4.0a0 + - libegl >=1.7.0,<2.0a0 + license: Zlib + run_exports: + weak: + - sdl2 >=2.32.56,<3.0a0 + size: 589145 + timestamp: 1757842881000 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.10-hdeec2a5_0.conda + sha256: 04fa7dab2b8f688e3fc4b7ae4522fd3935fb0601e3329cda8b40d63c60d6cc05 + md5: 845c0b154836c034f361668bec2a4f20 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - xorg-libxcursor >=1.2.3,<2.0a0 + - libusb >=1.0.29,<2.0a0 + - libxkbcommon >=1.13.2,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxi >=1.8.3,<2.0a0 + - liburing >=2.14,<2.15.0a0 + - libunwind >=1.8.3,<1.9.0a0 + - xorg-libxtst >=1.2.5,<2.0a0 + - wayland >=1.25.0,<2.0a0 + - dbus >=1.16.2,<2.0a0 + - libgl >=1.7.0,<2.0a0 + - xorg-libxscrnsaver >=1.2.4,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - libdrm >=2.4.127,<2.5.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - libegl >=1.7.0,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - libudev1 >=257.13 + license: Zlib + run_exports: + weak: + - sdl3 >=3.4.10,<4.0a0 + size: 2148830 + timestamp: 1780262823658 +- conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2026.2-h718be3e_0.conda + sha256: c6e3280867e54c97996a4fedda0ab72c92d48d1d69258bddf910130df72c169d + md5: 6438976979721e2f60ec47327d8d38df + depends: + - __glibc >=2.17,<3.0.a0 + - glslang >=16,<17.0a0 + - libgcc >=14 + - libstdcxx >=14 + - spirv-tools >=2026,<2027.0a0 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - shaderc >=2026.2,<2026.3.0a0 + size: 113684 + timestamp: 1777360595361 +- conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + sha256: 48f3f6a76c34b2cfe80de9ce7f2283ecb55d5ed47367ba91e8bb8104e12b8f11 + md5: 98b6c9dc80eb87b2519b97bcf7e578dd + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - snappy >=1.2.2,<1.3.0a0 + size: 45829 + timestamp: 1762948049098 +- conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.2-hb700be7_0.conda + sha256: 309d1a3317e91a03611bc960fc807cf2c0c5baacbfddea0f5636438a76c52256 + md5: 0c2b1d811632f1f4aa923450a002ff4f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - spirv-headers >=1.4.350.0,<1.4.350.1.0a0 + license: Apache-2.0 + license_family: APACHE + run_exports: + weak: + - spirv-tools >=2026,<2027.0a0 + size: 2392190 + timestamp: 1780139567779 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.3-hbc0de68_0.conda + sha256: ef17725138fa72aa5a0f8ccace9727831c4b333e39575f78fb5ad1755826b687 + md5: b345ea7f13e4cae9809c69b1d1af2c99 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libsqlite 3.53.3 h0c1763c_0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - readline >=8.3,<9.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.3,<4.0a0 + size: 206481 + timestamp: 1782519082269 +- conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.0.1-hecca717_0.conda + sha256: 4a1d2005153b9454fc21c9bad1b539df189905be49e851ec62a6212c2e045381 + md5: 2a2170a3e5c9a354d09e4be718c43235 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - svt-av1 >=4.0.1,<4.0.2.0a0 + size: 2619743 + timestamp: 1769664536467 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-hab88423_2.conda + sha256: 30cb9355c2fefc20ff1a3d6566b9714d5614086a2524c07721fc344eb20515ae + md5: 7073b15f9364ebc118998601ac6ca6a6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libhwloc >=2.13.0,<2.13.1.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 182331 + timestamp: 1778673758649 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-devel-2023.0.0-hab88423_2.conda + sha256: 47c82725569413b079632f5ad161b3bf2fa877d65dee1ff5c2c70a308a6150b5 + md5: 64a1e69ab772dd965d78be2734212667 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - tbb 2023.0.0 hab88423_2 + run_exports: + weak: + - tbb >=2023.0.0 + size: 1139342 + timestamp: 1778673771784 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3301196 + timestamp: 1769460227866 +- conda: https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py312h4c3975b_0.conda + sha256: 895bbfe9ee25c98c922799de901387d842d7c01cae45c346879865c6a907f229 + md5: 0b6c506ec1f272b685240e70a29261b8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: Apache + run_exports: {} + size: 410641 + timestamp: 1770909099497 +- conda: https://conda.anaconda.org/conda-forge/linux-64/utfcpp-4.09-ha770c72_0.conda + sha256: 18f84366d84b83bb4b2a6e0ac4487a5b4ee33c531faa2d822027fddf8225eed5 + md5: 99884244028fe76046e3914f90d4ad05 + license: BSL-1.0 + run_exports: {} + size: 14226 + timestamp: 1767012219987 +- conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.11.25-h26efc2c_0.conda + sha256: 64d5c1592d62e77191e7f61aae811db68d040c5507c5b1cb33010530148177dc + md5: fed7cb43d942f65256b6187fe30484b1 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - __glibc >=2.17 + license: Apache-2.0 OR MIT + run_exports: {} + size: 20451374 + timestamp: 1782538835059 +- conda: https://conda.anaconda.org/conda-forge/linux-64/viskores-1.1.1-cpu_hc82bd48_0.conda + sha256: 2ebe8d6ae8c302ca5bf4aec532cd75ebfb00240db1c14dad9a91bace65aa083d + md5: 24676eb7fd23ea3d8d69417f5f1224e0 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + - hdf5 >=1.14.6,<1.14.7.0a0 + - mesalib >=26.0.3,<26.1.0a0 + - glew >=2.3.0,<2.4.0a0 + - zfp >=1.0.1,<2.0a0 + track_features: + - viskores-p-0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - viskores >=1.1.1,<1.2.0a0 + size: 25058279 + timestamp: 1777494673829 +- conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-9.6.2-py312h244374b_2.conda + sha256: 9391ede8dca52caf6627b7f18b6c25f03e7e27511bf0f7b7103108ba82a7c60d + md5: 41509ad9bbded845f8d99d6c0a8c683b + depends: + - vtk-base >=9.6.2,<9.6.3.0a0 + - vtk-io-ffmpeg >=9.6.2,<9.6.3.0a0 + - libgl-devel + - libopengl-devel + - libboost-devel + - liblzma-devel + - tbb-devel + - eigen + - expat + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - vtk-base >=9.6.2,<9.6.3.0a0 + size: 28281 + timestamp: 1780078474771 +- conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-base-9.6.2-py312h5e1aeb2_2.conda + sha256: f750026e1ef70f92b291ed90e542e13f646ff12b6c243967ae8bac36a17b35aa + md5: aed0067520ed430bbfcdd9638e336e33 + depends: + - python + - utfcpp + - nlohmann_json + - cli11 + - numpy + - wslink + - matplotlib-base >=2.0.0 + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.8.1,<3.0a0 + - libglvnd >=1.7.0,<2.0a0 + - libglu >=9.0.3,<9.1.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libsqlite >=3.53.1,<4.0a0 + - libnetcdf >=4.10.0,<4.10.1.0a0 + - jsoncpp >=1.9.7,<1.9.8.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - python_abi 3.12.* *_cp312 + - qt6-main >=6.11.1,<6.12.0a0 + - pugixml >=1.15,<1.16.0a0 + - fmt >=12.1.0,<12.2.0a0 + - gl2ps >=1.4.2,<1.4.3.0a0 + - libxkbfile >=1.2.0,<2.0a0 + - libogg >=1.3.5,<1.4.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libglx >=1.7.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - liblzma >=5.8.3,<6.0a0 + - xorg-libxcursor >=1.2.3,<2.0a0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - eigen-abi >=5.0.1.80,<5.0.1.81.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.2,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - tbb >=2023.0.0 + - double-conversion >=3.4.0,<3.5.0a0 + - viskores >=1.1.1,<1.2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - proj >=9.8.1,<9.9.0a0 + - libtheora >=1.1.1,<1.2.0a0 + - libopengl >=1.7.0,<2.0a0 + constrains: + - libboost-headers >=1.90.0,<1.91.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - vtk-base >=9.6.2,<9.6.3.0a0 + size: 85788735 + timestamp: 1780078474771 +- conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-io-ffmpeg-9.6.2-py312h14f7233_2.conda + sha256: 8f9c24ee7f6916e1d45ce369f01bec96d6c382f51751d025878abf2e3b8d7a0d + md5: 43ffec13c8ef20c1bdc9d06691fad2f9 + depends: + - vtk-base ==9.6.2 py312h5e1aeb2_2 + - ffmpeg + - python_abi 3.12.* *_cp312 + - ffmpeg >=8.1.1,<9.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - vtk-io-ffmpeg >=9.6.2,<9.6.3.0a0 + size: 112846 + timestamp: 1780078474771 +- conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda + sha256: ea374d57a8fcda281a0a89af0ee49a2c2e99cc4ac97cf2e2db7064e74e764bdb + md5: 996583ea9c796e5b915f7d7580b51ea6 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.7.4,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - wayland >=1.25.0,<2.0a0 + size: 334139 + timestamp: 1773959575393 +- conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 + sha256: 175315eb3d6ea1f64a6ce470be00fa2ee59980108f246d3072ab8b977cb048a5 + md5: 6c99772d483f566d59e25037fea2c4b1 + depends: + - libgcc-ng >=12 + license: GPL-2.0-or-later + license_family: GPL + run_exports: + weak: + - x264 >=1!164.3095,<1!165 + size: 897548 + timestamp: 1660323080555 +- conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 + sha256: 76c7405bcf2af639971150f342550484efac18219c0203c5ee2e38b8956fe2a0 + md5: e7f6ed84d4623d52ee581325c1587a6b + depends: + - libgcc-ng >=10.3.0 + - libstdcxx-ng >=10.3.0 + license: GPL-2.0-or-later + license_family: GPL + run_exports: + weak: + - x265 >=3.5,<3.6.0a0 + size: 3357188 + timestamp: 1646609687141 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda + sha256: ad8cab7e07e2af268449c2ce855cbb51f43f4664936eff679b1f3862e6e4b01d + md5: fdc27cb255a7a2cc73b7919a968b48f0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libxcb >=1.17.0,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util >=0.4.1,<0.5.0a0 + size: 20772 + timestamp: 1750436796633 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda + sha256: c2be9cae786fdb2df7c2387d2db31b285cf90ab3bfabda8fa75a596c3d20fc67 + md5: 4d1fc190b99912ed557a8236e958c559 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libxcb >=1.13 + - libxcb >=1.17.0,<2.0a0 + - xcb-util-image >=0.4.0,<0.5.0a0 + - xcb-util-renderutil >=0.3.10,<0.4.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-cursor >=0.1.6,<0.2.0a0 + size: 20829 + timestamp: 1763366954390 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda + sha256: 94b12ff8b30260d9de4fd7a28cca12e028e572cbc504fd42aa2646ec4a5bded7 + md5: a0901183f08b6c7107aab109733a3c91 + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + - xcb-util >=0.4.1,<0.5.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-image >=0.4.0,<0.5.0a0 + size: 24551 + timestamp: 1718880534789 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda + sha256: 546e3ee01e95a4c884b6401284bb22da449a2f4daf508d038fdfa0712fe4cc69 + md5: ad748ccca349aec3e91743e08b5e2b50 + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-keysyms >=0.4.1,<0.5.0a0 + size: 14314 + timestamp: 1718846569232 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda + sha256: 2d401dadc43855971ce008344a4b5bd804aca9487d8ebd83328592217daca3df + md5: 0e0cbe0564d03a99afd5fd7b362feecd + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-renderutil >=0.3.10,<0.4.0a0 + size: 16978 + timestamp: 1718848865819 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda + sha256: 31d44f297ad87a1e6510895740325a635dd204556aa7e079194a0034cdd7e66a + md5: 608e0ef8256b81d04456e8d211eee3e8 + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xcb-util-wm >=0.4.2,<0.5.0a0 + size: 51689 + timestamp: 1718844051451 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + sha256: 3b04afd5d1a65d2d27ac2d49a63b01ab8bcd875776779ec63e337370ed38afdc + md5: b233b41be0bf210989d57160ed39b394 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - xorg-libx11 >=1.8.13,<2.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 441670 + timestamp: 1782027360439 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + sha256: c12396aabb21244c212e488bbdc4abcdef0b7404b15761d9329f5a4a39113c4b + md5: fb901ff28063514abb6046c9ec2c4a45 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libice >=1.1.2,<2.0a0 + size: 58628 + timestamp: 1734227592886 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + sha256: 277841c43a39f738927145930ff963c5ce4c4dacf66637a3d95d802a64173250 + md5: 1c74ff8c35dcadf952a16f752ca5aa49 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libuuid >=2.38.1,<3.0a0 + - xorg-libice >=1.1.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libsm >=1.2.6,<2.0a0 + size: 27590 + timestamp: 1741896361728 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + sha256: 516d4060139dbb4de49a4dcdc6317a9353fb39ebd47789c14e6fe52de0deee42 + md5: 861fb6ccbc677bb9a9fb2468430b9c6a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libxcb >=1.17.0,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libx11 >=1.8.13,<2.0a0 + size: 839652 + timestamp: 1770819209719 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + sha256: 6bc6ab7a90a5d8ac94c7e300cc10beb0500eeba4b99822768ca2f2ef356f731b + md5: b2895afaf55bf96a8c8282a2e47a5de0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxau >=1.0.12,<2.0a0 + size: 15321 + timestamp: 1762976464266 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda + sha256: 048c103000af9541c919deef03ae7c5e9c570ffb4024b42ecb58dbde402e373a + md5: f2ba4192d38b6cef2bb2c25029071d90 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxcomposite >=0.4.7,<1.0a0 + size: 14415 + timestamp: 1770044404696 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + sha256: 832f538ade441b1eee863c8c91af9e69b356cd3e9e1350fff4fe36cc573fc91a + md5: 2ccd714aa2242315acaf0a67faea780b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + - xorg-libxrender >=0.9.11,<0.10.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxcursor >=1.2.3,<2.0a0 + size: 32533 + timestamp: 1730908305254 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + sha256: 43b9772fd6582bf401846642c4635c47a9b0e36ca08116b3ec3df36ab96e0ec0 + md5: b5fcc7172d22516e1f965490e65e33a4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxdamage >=1.1.6,<2.0a0 + size: 13217 + timestamp: 1727891438799 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + sha256: 25d255fb2eef929d21ff660a0c687d38a6d2ccfbcbf0cc6aa738b12af6e9d142 + md5: 1dafce8548e38671bea82e3f5c6ce22f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxdmcp >=1.1.5,<2.0a0 + size: 20591 + timestamp: 1762976546182 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + sha256: 79c60fc6acfd3d713d6340d3b4e296836a0f8c51602327b32794625826bd052f + md5: 34e54f03dfea3e7a2dcf1453a85f1085 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxext >=1.3.7,<2.0a0 + size: 50326 + timestamp: 1769445253162 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + sha256: 83c4c99d60b8784a611351220452a0a85b080668188dce5dfa394b723d7b64f4 + md5: ba231da7fccf9ea1e768caf5c7099b84 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxfixes >=6.0.2,<7.0a0 + size: 20071 + timestamp: 1759282564045 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + sha256: 495f99c8eacfa4ae2d8fed2a7f2105777af89acdc204df145d2bbbc380ac631b + md5: adba2e334082bb218db806d4c12277c9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxi >=1.8.3,<2.0a0 + size: 47717 + timestamp: 1779111857071 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + sha256: 80ed047a5cb30632c3dc5804c7716131d767089f65877813d4ae855ee5c9d343 + md5: e192019153591938acf7322b6459d36e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxrandr >=1.5.5,<2.0a0 + size: 30456 + timestamp: 1769445263457 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + sha256: 044c7b3153c224c6cedd4484dd91b389d2d7fd9c776ad0f4a34f099b3389f4a1 + md5: 96d57aba173e878a2089d5638016dc5e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxrender >=0.9.12,<0.10.0a0 + size: 33005 + timestamp: 1734229037766 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda + sha256: 58e8fc1687534124832d22e102f098b5401173212ac69eb9fd96b16a3e2c8cb2 + md5: 303f7a0e9e0cd7d250bb6b952cecda90 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxscrnsaver >=1.2.4,<2.0a0 + size: 14412 + timestamp: 1727899730073 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxshmfence-1.3.3-hb9d3cd8_0.conda + sha256: c0830fe9fa78d609cd9021f797307e7e0715ef5122be3f784765dad1b4d8a193 + md5: 9a809ce9f65460195777f2f2116bae02 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxshmfence >=1.3.3,<2.0a0 + size: 12302 + timestamp: 1734168591429 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + sha256: 752fdaac5d58ed863bbf685bb6f98092fe1a488ea8ebb7ed7b606ccfce08637a + md5: 7bbe9a0cc0df0ac5f5a8ad6d6a11af2f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxi >=1.7.10,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxtst >=1.2.5,<2.0a0 + size: 32808 + timestamp: 1727964811275 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda + sha256: 64db17baaf36fa03ed8fae105e2e671a7383e22df4077486646f7dbf12842c9f + md5: 665d152b9c6e78da404086088077c844 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - xorg-libxxf86vm >=1.1.7,<2.0a0 + size: 18701 + timestamp: 1769434732453 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-hb03c661_0.conda + sha256: 7a8c64938428c2bfd016359f9cb3c44f94acc256c6167dbdade9f2a1f5ca7a36 + md5: aa8d21be4b461ce612d8f5fb791decae + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: {} + size: 570010 + timestamp: 1766154256151 +- conda: https://conda.anaconda.org/conda-forge/linux-64/yarl-1.24.2-py312h8a5da7c_0.conda + sha256: 9906e3e09ea7b734325cce2ebe7ac9a1d645d49e71823bffa54d9bf157c6b3ed + md5: 348307a7ed6137b1022f3809e2762f39 + depends: + - __glibc >=2.17,<3.0.a0 + - idna >=2.0 + - libgcc >=14 + - multidict >=4.0 + - propcache >=0.2.1 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 + license_family: Apache + run_exports: {} + size: 155061 + timestamp: 1779246264888 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zfp-1.0.1-h909a3a2_5.conda + sha256: 5fabe6cccbafc1193038862b0b0d784df3dae84bc48f12cac268479935f9c8b7 + md5: 6a0eb48e58684cca4d7acc8b7a0fd3c7 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zfp >=1.0.1,<2.0a0 + size: 277694 + timestamp: 1766549572069 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda + sha256: ea4e50c465d70236408cb0bfe0115609fd14db1adcd8bd30d8918e0291f8a75f + md5: 2aadb0d17215603a82a2a6b0afd9a4cb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Zlib + license_family: Other + run_exports: + weak: + - zlib-ng >=2.3.3,<2.4.0a0 + size: 122618 + timestamp: 1770167931827 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + sha256: a3967b937b9abf0f2a99f3173fa4630293979bd1644709d89580e7c62a544661 + md5: aaa2a381ccc56eac91d63b6c1240312f + depends: + - cpython + - python-gil + license: MIT + license_family: MIT + run_exports: {} + size: 8191 + timestamp: 1744137672556 +- conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.2-pyhd8ed1ab_0.conda + sha256: 6c6ddfeefead96d44f09c955b04967a579583af2dc63518faf029e46825e41ab + md5: 8a9936643c4a9565459c4a8eb5d4e3ff + depends: + - python >=3.10 + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 20727 + timestamp: 1779297825279 +- conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + sha256: 8dc149a6828d19bf104ea96382a9d04dae185d4a03cc6beb1bc7b84c428e3ca2 + md5: 421a865222cd0c9d83ff08bc78bf3a61 + depends: + - frozenlist >=1.1.0 + - python >=3.9 + - typing_extensions >=4.2 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 13688 + timestamp: 1751626573984 +- conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + sha256: 1b6124230bb4e571b1b9401537ecff575b7b109cc3a21ee019f65e083b8399ab + md5: c6b0543676ecb1fb2d7643941fe375f2 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 64927 + timestamp: 1773935801332 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + sha256: f8e3c730fa14ee3f170493779f06522c4acf89169f43db4f039727709b6419cf + md5: a9965dd99f683c5f444428f896635716 + depends: + - __unix + license: ISC + run_exports: {} + size: 128866 + timestamp: 1781708962055 +- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda + noarch: generic + sha256: d3e9bbd7340199527f28bbacf947702368f31de60c433a16446767d3c6aaf6fe + md5: f54c1ffb8ecedb85a8b7fcde3a187212 + depends: + - python >=3.12,<3.13.0a0 + - python_abi * *_cp312 + license: Python-2.0 + run_exports: {} + size: 46463 + timestamp: 1772728929620 +- conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + sha256: bb47aec5338695ff8efbddbc669064a3b10fe34ad881fb8ad5d64fbfa6910ed1 + md5: 4c2a8fef270f6c69591889b93f9f55c1 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 14778 + timestamp: 1764466758386 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + sha256: 58d7f40d2940dd0a8aa28651239adbf5613254df0f75789919c4e6762054403b + md5: 0c96522c6bdaed4b1566d11387caaf45 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 397370 + timestamp: 1566932522327 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + sha256: c52a29fdac682c20d252facc50f01e7c2e7ceac52aa9817aaf0bb83f7559ec5c + md5: 34893075a5c9e55cdafac56607368fc6 + license: OFL-1.1 + license_family: Other + run_exports: {} + size: 96530 + timestamp: 1620479909603 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + sha256: 00925c8c055a2275614b4d983e1df637245e19058d79fc7dd1a93b8d9fb4b139 + md5: 4d59c254e01d9cde7957100457e2d5fb + license: OFL-1.1 + license_family: Other + run_exports: {} + size: 700814 + timestamp: 1620479612257 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + sha256: 2821ec1dc454bd8b9a31d0ed22a7ce22422c0aef163c59f49dfdf915d0f0ca14 + md5: 49023d73832ef61042f6a237cb2687e7 + license: LicenseRef-Ubuntu-Font-Licence-Version-1.0 + license_family: Other + run_exports: {} + size: 1620504 + timestamp: 1727511233259 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + sha256: a997f2f1921bb9c9d76e6fa2f6b408b7fa549edd349a77639c9fe7a23ea93e61 + md5: fee5683a3f04bd15cbd8318b096a27ab + depends: + - fonts-conda-forge + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 3667 + timestamp: 1566974674465 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + sha256: 54eea8469786bc2291cc40bca5f46438d3e062a399e8f53f013b6a9f50e98333 + md5: a7970cd949a077b7cb9696379d338681 + depends: + - font-ttf-ubuntu + - font-ttf-inconsolata + - font-ttf-dejavu-sans-mono + - font-ttf-source-code-pro + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 4059 + timestamp: 1762351264405 +- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda + sha256: c75632ea624aa450a394f570749420c5a2e0997d0216bc29d5d45b0f39df0426 + md5: 577b04680ae422adb86fc60d7b940659 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 163869 + timestamp: 1781620148226 +- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + sha256: 41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a + md5: 86d9cba083cd041bfbf242a01a7a1999 + constrains: + - sysroot_linux-64 ==2.28 + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 1278712 + timestamp: 1765578681495 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.3.0-hf649bbc_119.conda + sha256: e1815bb11d5abe886979e95889d84310d83d078d36a3567ca67cbf57a3876d88 + md5: 7d517e32d656a8880d98c0e4fc8ddc2c + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 3091520 + timestamp: 1778268364856 +- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.3.0-h9f08a49_119.conda + sha256: 1b4263aa5d8c8c659e8e38b66868f42867347e0c8941513ee77269afc00a5186 + md5: d1a866495b9654ccfef5392b8541dc58 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 20199810 + timestamp: 1778268389428 +- conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + sha256: d09c47c2cf456de5c09fa66d2c3c5035aa1fa228a1983a433c47b876aa16ce90 + md5: 37293a85a0f4f77bbd9cf7aaefc62609 + depends: + - python >=3.9 + license: Apache-2.0 + license_family: Apache + run_exports: {} + size: 15851 + timestamp: 1749895533014 +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + sha256: 3906abfb6511a3bb309e39b9b1b7bc38f50a723971de2395489fd1f379255890 + md5: 4c06a92e74452cfa53623a81592e8934 + depends: + - python >=3.8 + - python + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 91574 + timestamp: 1777103621679 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda + sha256: 417fba4783e528ee732afa82999300859b065dc59927344b4859c64aae7182de + md5: 3687cc0b82a8b4c17e1f0eb7e47163d5 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 110893 + timestamp: 1769003998136 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + sha256: d6a17ece93bbd5139e02d2bd7dbfa80bee1a4261dced63f65f679121686bf664 + md5: 5b8d21249ff20967101ffa321cab24e8 + depends: + - python >=3.9 + - six >=1.5 + - python + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 233310 + timestamp: 1751104122689 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda + sha256: 97327b9509ae3aae28d27217a5d7bd31aff0ab61a02041e9c6f98c11d8a53b29 + md5: 32780d6794b8056b78602103a04e90ef + depends: + - cpython 3.12.13.* + - python_abi * *_cp312 + license: Python-2.0 + run_exports: {} + size: 46449 + timestamp: 1772728979370 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + build_number: 8 + sha256: 80677180dd3c22deb7426ca89d6203f1c7f1f256f2d5a94dc210f6e758229809 + md5: c3efd25ac4d74b1584d2f7a57195ddf1 + constrains: + - python 3.12.* *_cpython + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6958 + timestamp: 1752805918820 +- conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d + md5: 3339e3b65d58accf4ca4fb8748ab16b3 + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 18455 + timestamp: 1753199211006 +- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + sha256: c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b851 + md5: 13dc3adbc692664cd3beabd216434749 + depends: + - __glibc >=2.28 + - kernel-headers_linux-64 4.18.0 he073ed8_9 + - tzdata + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: + strong: + - __glibc >=2.28,<3.0.a0 + size: 24008591 + timestamp: 1765578833462 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 + md5: 0caa1af407ecff61170c9437a808404d + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 51692 + timestamp: 1756220668932 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + run_exports: {} + size: 119135 + timestamp: 1767016325805 +- conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.49-hd8ed1ab_0.conda + sha256: 04ce686cd187d379344f9b2be7b4da5f431b265dc0944a6b764fab9da9171948 + md5: 0839a3421140d4a9ba93fb988698fc00 + license: MIT + license_family: MIT + run_exports: {} + size: 147954 + timestamp: 1780946721169 +- conda: https://conda.anaconda.org/conda-forge/noarch/wslink-2.5.7-pyhd8ed1ab_0.conda + sha256: 894762dc1a6520888de7cbe8d6f59999a94005aad11951fddcfdbef85d726a2d + md5: 410dee7cbee308f33b01645f16f11efc + depends: + - aiohttp <4 + - msgpack-python >=1,<2 + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 36803 + timestamp: 1778826669944 diff --git a/packages/dimos-gpd-grasp-demo/pixi.toml b/packages/dimos-gpd-grasp-demo/pixi.toml new file mode 100644 index 0000000000..581eb9590e --- /dev/null +++ b/packages/dimos-gpd-grasp-demo/pixi.toml @@ -0,0 +1,14 @@ +[workspace] +name = "dimos-gpd-grasp-demo" +channels = ["conda-forge"] +platforms = ["linux-64"] + +[dependencies] +cmake = ">=3.20" +compilers = "*" +eigen = "*" +opencv = "*" +pcl = "*" +pkg-config = "*" +python = ">=3.11,<3.13" +uv = "*" diff --git a/packages/dimos-gpd-grasp-demo/pyproject.toml b/packages/dimos-gpd-grasp-demo/pyproject.toml new file mode 100644 index 0000000000..aa6350511e --- /dev/null +++ b/packages/dimos-gpd-grasp-demo/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "dimos-gpd-grasp-demo" +version = "0.1.0" +description = "DimOS project-runtime GPD grasp detection demo package." +requires-python = ">=3.11" +dependencies = [ + "dimos", + "gpd @ git+https://github.com/TomCC7/gpd.git@c088d8ae2f7965b067e9a12b3c0dacdbe9da924a", +] + +[tool.uv.sources] +dimos = { path = "../..", editable = true } + +[tool.hatch.metadata] +allow-direct-references = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/__init__.py b/packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/__init__.py new file mode 100644 index 0000000000..ed26df930c --- /dev/null +++ b/packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/__init__.py @@ -0,0 +1,25 @@ +from dimos_gpd_grasp_demo.blueprint import ( + GPD_GRASP_DEMO_ENV_NAME, + GPD_GRASP_DEMO_PROJECT, + GpdGraspImportProbe, + gpd_grasp_demo_blueprint, + gpd_grasp_gen_blueprint, +) +from dimos_gpd_grasp_demo.gpd_grasp_gen_module import ( + GPD_RUNTIME_HELP, + GPDGraspGenModule, + NormalizedGraspCandidate, + pointcloud_to_gpd_xyz, +) + +__all__ = [ + "GPD_GRASP_DEMO_ENV_NAME", + "GPD_GRASP_DEMO_PROJECT", + "GPD_RUNTIME_HELP", + "GPDGraspGenModule", + "GpdGraspImportProbe", + "NormalizedGraspCandidate", + "gpd_grasp_demo_blueprint", + "gpd_grasp_gen_blueprint", + "pointcloud_to_gpd_xyz", +] diff --git a/packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/blueprint.py b/packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/blueprint.py new file mode 100644 index 0000000000..f69fd3a3a6 --- /dev/null +++ b/packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/blueprint.py @@ -0,0 +1,65 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.core import rpc +from dimos.core.module import Module +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment +from dimos_gpd_grasp_demo.gpd_grasp_gen_module import GPDGraspGenModule + +if TYPE_CHECKING: + from dimos.core.coordination.blueprints import Blueprint + +GPD_GRASP_DEMO_ENV_NAME = "dimos-gpd-grasp-demo" +GPD_GRASP_DEMO_PROJECT = Path(__file__).resolve().parents[2] + + +class GpdGraspImportProbe(Module): + @rpc + def import_gpd_core(self) -> str: + """Import gpd.core in the grasp demo worker runtime and report success.""" + import gpd.core as gpd_core + + module_file = getattr(gpd_core, "__file__", "") + module_name = getattr(gpd_core, "__name__", "gpd.core") + return f"gpd import ok: {module_name} ({module_file})" + + +def gpd_grasp_demo_blueprint( + runtime: PythonProjectRuntimeEnvironment | None = None, +) -> Blueprint: + environment = runtime or PythonProjectRuntimeEnvironment( + name=GPD_GRASP_DEMO_ENV_NAME, + project=GPD_GRASP_DEMO_PROJECT, + ) + return autoconnect(GpdGraspImportProbe.blueprint()).runtime_environments( + environment + ).runtime_placements({GpdGraspImportProbe: environment.name}) + + +def gpd_grasp_gen_blueprint( + runtime: PythonProjectRuntimeEnvironment | None = None, +) -> Blueprint: + environment = runtime or PythonProjectRuntimeEnvironment( + name=GPD_GRASP_DEMO_ENV_NAME, + project=GPD_GRASP_DEMO_PROJECT, + ) + return autoconnect(GPDGraspGenModule.blueprint()).runtime_environments( + environment + ).runtime_placements({GPDGraspGenModule: environment.name}) diff --git a/packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/gpd_grasp_gen_module.py b/packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/gpd_grasp_gen_module.py new file mode 100644 index 0000000000..c80edbcff5 --- /dev/null +++ b/packages/dimos-gpd-grasp-demo/src/dimos_gpd_grasp_demo/gpd_grasp_gen_module.py @@ -0,0 +1,201 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +from typing import Protocol, cast + +import numpy as np + +from dimos.core.core import rpc +from dimos.core.module import Module +from dimos.core.stream import Out +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseArray import PoseArray +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.GraspCandidate import GraspCandidate +from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.msgs.std_msgs.Header import Header + +GPD_RUNTIME_HELP = ( + "GPD grasp detection backend is unavailable. Prepare/install the " + "dimos-gpd-grasp-demo project runtime (for example, prepare the Pixi runtime " + "for packages/dimos-gpd-grasp-demo) so `from gpd.core import Cloud, " + "GraspDetector` works in the placed worker." +) + + +@dataclass(frozen=True, slots=True) +class NormalizedGraspCandidate: + position: tuple[float, float, float] + orientation_xyzw: tuple[float, float, float, float] + score: float = 0.0 + width: float = 0.08 + + +class _Backend(Protocol): + def __call__(self, points_xyz: np.ndarray) -> Sequence[object]: ... + + +def pointcloud_to_gpd_xyz(pointcloud: PointCloud2) -> np.ndarray: + """Convert DimOS PointCloud2 to contiguous finite float32 XYZ input for GPD.""" + points = pointcloud.points_f32() + if points.ndim != 2 or points.shape[1] != 3: + raise ValueError(f"PointCloud2 positions must have shape (N, 3), got {points.shape}") + if points.shape[0] == 0: + return np.zeros((0, 3), dtype=np.float32) + finite = np.isfinite(points).all(axis=1) + return np.ascontiguousarray(points[finite], dtype=np.float32) + + +class GPDGraspGenModule(Module): + """Generate grasp poses from existing PointCloud2 inputs using a lazy GPD backend. + + Backend results without score/width metadata are published with safe debug defaults: + score=0.0 (unknown quality) and width=0.08m (the Rerun visualization default). + """ + + grasp_candidates: Out[GraspCandidateArray] + + def __init__( + self, + backend: Callable[[np.ndarray], Sequence[object]] | None = None, + default_score: float = 0.0, + default_width_m: float = 0.08, + **kwargs: object, + ) -> None: + super().__init__(**kwargs) + self._backend = backend + self._default_score = default_score + self._default_width_m = default_width_m + + @rpc + def generate_grasps( + self, + pointcloud: PointCloud2, + scene_pointcloud: PointCloud2 | None = None, + ) -> PoseArray | None: + """Generate grasp poses from an object PointCloud2 and optional scene cloud.""" + del scene_pointcloud + points = pointcloud_to_gpd_xyz(pointcloud) + if points.shape[0] == 0: + empty = self._candidate_array(pointcloud, []) + self.grasp_candidates.publish(empty) + return None + + raw_grasps = self._detect(points) + normalized = [self._normalize_grasp(grasp) for grasp in raw_grasps] + candidates = self._candidate_array(pointcloud, normalized) + self.grasp_candidates.publish(candidates) + if len(candidates) == 0: + return None + return candidates.to_pose_array() + + def _detect(self, points_xyz: np.ndarray) -> Sequence[object]: + if self._backend is not None: + return self._backend(points_xyz) + return _run_gpd_backend(points_xyz) + + def _candidate_array( + self, pointcloud: PointCloud2, grasps: Iterable[NormalizedGraspCandidate] + ) -> GraspCandidateArray: + timestamp = pointcloud.ts if pointcloud.ts is not None else 0.0 + header = Header(float(timestamp), pointcloud.frame_id) + return GraspCandidateArray( + header=header, + candidates=[ + GraspCandidate( + pose=Pose(Vector3(grasp.position), Quaternion(grasp.orientation_xyzw)), + jaw_width=grasp.width, + score=grasp.score, + ) + for grasp in grasps + ], + ) + + def _normalize_grasp(self, grasp: object) -> NormalizedGraspCandidate: + if isinstance(grasp, NormalizedGraspCandidate): + return grasp + position = _first_attr(grasp, ("position", "translation", "center")) + orientation = _first_attr(grasp, ("orientation", "quaternion", "rotation")) + score = _optional_float(grasp, ("score", "quality"), self._default_score) + width = _optional_float(grasp, ("width", "jaw_width", "grasp_width"), self._default_width_m) + return NormalizedGraspCandidate( + position=_position_tuple(position), + orientation_xyzw=_orientation_tuple(orientation), + score=score, + width=width, + ) + + +def _run_gpd_backend(points_xyz: np.ndarray) -> Sequence[object]: + try: + from gpd.core import Cloud, GraspDetector # type: ignore[import-not-found] + except ImportError as exc: + raise RuntimeError(GPD_RUNTIME_HELP) from exc + + try: + cloud = Cloud(points_xyz) + detector = GraspDetector.from_preset("eigen") + return cast("Sequence[object]", detector.detect_grasps(cloud)) + except AttributeError as exc: + raise RuntimeError(f"{GPD_RUNTIME_HELP} The installed gpd.core API is incompatible.") from exc + + +def _first_attr(grasp: object, names: tuple[str, ...]) -> object: + if isinstance(grasp, dict): + for name in names: + if name in grasp: + return grasp[name] + for name in names: + if hasattr(grasp, name): + return getattr(grasp, name) + raise ValueError(f"GPD grasp result missing one of: {', '.join(names)}") + + +def _optional_float(grasp: object, names: tuple[str, ...], default: float) -> float: + try: + return float(cast("int | float | str", _first_attr(grasp, names))) + except ValueError: + return default + + +def _position_tuple(value: object) -> tuple[float, float, float]: + seq = _float_sequence(value) + if len(seq) != 3: + raise ValueError("GPD grasp position must contain 3 values") + return (seq[0], seq[1], seq[2]) + + +def _orientation_tuple(value: object) -> tuple[float, float, float, float]: + array = np.asarray(value, dtype=np.float64) + if array.shape == (3, 3): + quat = Quaternion.from_rotation_matrix(array).normalize() + return quat.to_tuple() + seq = _float_sequence(value) + if len(seq) != 4: + raise ValueError("GPD grasp orientation must be a quaternion or 3x3 rotation matrix") + quat = Quaternion(seq).normalize() + return quat.to_tuple() + + +def _float_sequence(value: object) -> list[float]: + if isinstance(value, np.ndarray): + return [float(item) for item in value.reshape(-1).tolist()] + if not isinstance(value, Sequence) or isinstance(value, str | bytes): + raise ValueError(f"Expected numeric sequence, got {type(value).__name__}") + return [float(item) for item in cast("Sequence[int | float]", value)] diff --git a/packages/dimos-gpd-grasp-demo/uv.lock b/packages/dimos-gpd-grasp-demo/uv.lock new file mode 100644 index 0000000000..ed056223c7 --- /dev/null +++ b/packages/dimos-gpd-grasp-demo/uv.lock @@ -0,0 +1,4227 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "addict" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ef/fd7649da8af11d93979831e8f1f8097e85e82d5bfeabc8c68b39175d8e75/addict-2.4.0.tar.gz", hash = "sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494", size = 9186, upload-time = "2020-11-21T16:21:31.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiohttp-jinja2" +version = "1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/39/da5a94dd89b1af7241fb7fc99ae4e73505b5f898b540b6aba6dc7afe600e/aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2", size = 53057, upload-time = "2023-11-18T15:30:52.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/90/65238d4246307195411b87a07d03539049819b022c01bcc773826f600138/aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7", size = 11736, upload-time = "2023-11-18T15:30:50.743Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "annotation-protocol" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/fd/612c96531b1c1d1c06e5d79547faea3f805785d67481b350f3f6a9cf6dc5/annotation_protocol-1.4.0.tar.gz", hash = "sha256:15d846a4984339bab6cbf80a44623219b8cb06b4f4fee0f22c31a255d16900f8", size = 8470, upload-time = "2026-01-19T08:48:27.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8b/71a5e1392dd3aca7ffeef0c3b10ea9b0e62959b5f39889702a06e11eda96/annotation_protocol-1.4.0-py3-none-any.whl", hash = "sha256:6fc66f1506f015db16fdd50fad18520cbb126a7902b27257c9fa521eb5efec60", size = 7834, upload-time = "2026-01-19T08:48:25.848Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "bleak" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-radios", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/df/05a3f80ca8e3f7f5b0dba68a9e618147c909ccdba1468f07487dc8d72a9d/bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f", size = 125293, upload-time = "2026-05-02T23:01:04.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "cmeel" +version = "0.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/46/ddc7df697e49cae32b1a97b3e1a3b47b815238f9059312f987bc62a2e756/cmeel-0.60.1.tar.gz", hash = "sha256:3e0b92eb933a3693ad3f1da8aae31defcbee5f25969daaf20e59c57d6a9474cf", size = 14972, upload-time = "2026-05-11T17:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/39/f2db2ff475d42222c70fe25c737028aeaafdd9c0aeba04e6b2dc66e7f93f/cmeel-0.60.1-py3-none-any.whl", hash = "sha256:3f92b68353a58b4d6b5a664ea96bf58a3fef0891a8ed570d3c153361bbbb94b7", size = 20612, upload-time = "2026-05-11T17:13:55.812Z" }, +] + +[[package]] +name = "cmeel-assimp" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-zlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/57/444bedd35567a8d6b61850b03aadf6b0d2d20737ee79c620c0e40f9a56dc/cmeel_assimp-6.0.5-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:840ab4bd398abbec5a612a731bb6f7fdc4e21b2708ae0dc0f99d7ff6d9992624", size = 10057855, upload-time = "2026-05-20T16:36:19.775Z" }, + { url = "https://files.pythonhosted.org/packages/6f/96/d4d128e074f59b79e90c78b8379127bd8953c72d1ad8a65229c23a09814f/cmeel_assimp-6.0.5-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d81f913e9efc8526a335900325906b85dc57891eeb7ece463714a107f14f63f", size = 9216472, upload-time = "2026-05-20T16:36:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/77/37/11de8f263a5cde3cde4796e344ff034674bdbb7a2381f6b5303d49b24def/cmeel_assimp-6.0.5-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:db6fe58f8bd633cb05ab9f390c576a4e774927ed8c343083fad4f939e834bf6e", size = 13709230, upload-time = "2026-05-20T16:36:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/62/66/7f12b2f003671828a46ab35964aed0a34632fcba5638b4049bdd918cf342/cmeel_assimp-6.0.5-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7b5ee36f3027de9b1bd73bd4294b60fed1bbc6c1524f94f64f69f38c5a49bf03", size = 14840275, upload-time = "2026-05-20T16:36:28.311Z" }, +] + +[[package]] +name = "cmeel-boost" +version = "1.90.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/01/ab2ea5b1ceae6283eb68be8beac529f294c70ac49d40b7873b0b639a7784/cmeel_boost-1.90.0.tar.gz", hash = "sha256:747d6d932838df26f50cdcb5983fa7532cad1d9ccce46c9f9d8b27b20a115981", size = 4085, upload-time = "2026-05-20T15:19:13.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/0c/3e8c134393001b5f7dcf8c8aa1673b2126b271e1d475541a1fe0c1ca8bac/cmeel_boost-1.90.0-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0881a638b53d19340bab8bcc21b108999b20a8ed3a4db0a761b53b8116ad551", size = 30799100, upload-time = "2026-05-20T15:17:52.887Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bf/3383e9bdf24176f9815c98f39a34901a9019a05b587d32d7e901e4fd5a91/cmeel_boost-1.90.0-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:853b2cfb491733d782ae163adc6378baaccd8dc9752870677498ccb0eaea125a", size = 30693292, upload-time = "2026-05-20T15:17:57.243Z" }, + { url = "https://files.pythonhosted.org/packages/d7/30/c19fdd54edbb1bef7d8c403752d7055ddbffcd757a947074517f3d18e414/cmeel_boost-1.90.0-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:653285a31001ad30a9b6fc95c0f86ee9f65b4ab5d868f649a2df3472db53d368", size = 35828987, upload-time = "2026-05-20T15:18:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/07/52/ff72d2e3950a09e9fe9714d5952597f695efd5efcdc388d3781cec1910f0/cmeel_boost-1.90.0-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14efe34db660c9aacb61247a7f9ae0ee6d7626ec9a3d89b8b27e62dba6fcaa4d", size = 36162775, upload-time = "2026-05-20T15:18:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/5c/a01c51d296f59bf36851e8d23387d7d09952c99ed9dbc26ed0c502ed05c3/cmeel_boost-1.90.0-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:506c315ff4b48c2e7d146787541319f22d9b63a96defba7f963e3402c5e5eca1", size = 30801862, upload-time = "2026-05-20T15:18:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3e/c6d597586bbef678f19c19f10b5b1f75203861d3acba41b2ec0cd3fca6d5/cmeel_boost-1.90.0-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34657195b40faf0d090a55ee736db4b69938241730b8786e4b3430e4487519de", size = 30695049, upload-time = "2026-05-20T15:18:15.845Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/3f590f6446afa13af6232c18020ed61c72418f94f060ae6cc30af28b6b7d/cmeel_boost-1.90.0-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0b9546ad7cdffd1d633bd878e8334adedb2efe099e72f965157e49c474cb463f", size = 35830172, upload-time = "2026-05-20T15:18:21.109Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/c4dd452193fc017e80a8b0abd03c4bcbf45cebb21b87006268062cf2df8b/cmeel_boost-1.90.0-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c306b9af69d13502a6598672a341deb4f549fa3eb8cfb363de190e7409919a4a", size = 36167681, upload-time = "2026-05-20T15:18:26.397Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a2/1298a943326a2676fb01315580097afdbb4ed7713eec8a43501ea734afad/cmeel_boost-1.90.0-0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:32eb2faf91bf7fcd1858a7a1adddb3b92d7c65d305b4800fc41e0a8a53d29534", size = 30801906, upload-time = "2026-05-20T15:18:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/be/54/d6eb64585a9ad92699ad43a035f0eccff3d6ab0df9938232329bc710c645/cmeel_boost-1.90.0-0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c4fb3a1e0b04a2c4c871f0e72b55f6f381b14b110002505276a7831be24d348", size = 30695070, upload-time = "2026-05-20T15:18:36.371Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a6/948afc3ad0eed1b1f0f482191293b4ea57e6da853842121cb6bdb36538bc/cmeel_boost-1.90.0-0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:83efcb881da48f3645d4eae4ddb2bde6e2ae241b3b0a9c9abae6bdfcc0ca4ae6", size = 35830386, upload-time = "2026-05-20T15:18:42.294Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/a153892d8d8f5624eed29a933779d6ff00c144dd9dc6f70b41f98377f39a/cmeel_boost-1.90.0-0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:deeefeae790e4a9ac200ece07c935faf415e48933ccffd7a692682f7f4c3f524", size = 36167733, upload-time = "2026-05-20T15:18:47.775Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e9/533f21bacafc50cb13ad9e305992910b3719d105e1a303508eb09338f4ef/cmeel_boost-1.90.0-0-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:022a3e3b059921e5147fd6f2b47f4ee787a83210157c6d70443b65190dcdfd5f", size = 30804533, upload-time = "2026-05-20T15:18:53.572Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/fcfbec49b9cbbb3814d1df8638aa4af75a2134578417f11e9dfaedb00a20/cmeel_boost-1.90.0-0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec217d419adbdc486af7deab664bccd6aa5d4d69878ce7df374c29482b10d731", size = 30695643, upload-time = "2026-05-20T15:18:58.999Z" }, + { url = "https://files.pythonhosted.org/packages/33/98/98c56099e55ad4532d539613bb8f4acbf61b3ece2d8964738ee721d6a73c/cmeel_boost-1.90.0-0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7527de3b45851da92fca44c61fb5f6562fd61e15d7f1dc071c63f55d672becc4", size = 35836813, upload-time = "2026-05-20T15:19:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/cdc8fe9402b6ef844e87c7aaedd411a63a827e1f332cdf590c8d8d3c929d/cmeel_boost-1.90.0-0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5fda3fb51276d3ec95d9e035af69b3c2c4032b681ff10d67918109043296b8e9", size = 36174146, upload-time = "2026-05-20T15:19:09.696Z" }, +] + +[[package]] +name = "cmeel-console-bridge" +version = "1.0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/13/2e9e9d23db8548aef975564055bdb4fb6da8a397a1e7df8cb61f5afebefb/cmeel_console_bridge-1.0.2.3.tar.gz", hash = "sha256:3b2837da7ab408e9d1a775c83c0a7772356062b3a3672e4ce247f2da71a8ecd9", size = 262061, upload-time = "2025-03-19T18:22:06.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/a7/527fa060e5881acb3b0a07bf1d803ccb831cb87739abb62b6bcd14f5aed3/cmeel_console_bridge-1.0.2.3-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:7aa19b2d006073a1fad55d32968c7d0c7136749e06f98405f4f73a71038a5c41", size = 21341, upload-time = "2025-03-19T18:21:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/bb/db/f8643a8766e8909e0dbfcda6191ca92454cf9a3fadd89be417db261601a1/cmeel_console_bridge-1.0.2.3-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c47d8c97cb120feed1c01f30845d16c67e4e8205941e3977951018972b9b8721", size = 21286, upload-time = "2025-03-19T18:21:57.984Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/9c79177152a220ab2e4ffa0140722165035f6a5c2abbed2912352bd7e7b9/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_17_i686.whl", hash = "sha256:cad9723ac44ab563cd23bf361b604733623d11847c4edf2a2b4ebd1d984ade09", size = 23740, upload-time = "2025-03-19T18:21:59.683Z" }, + { url = "https://files.pythonhosted.org/packages/92/65/5741de6f550fe701d0780546d97b283306676315a3e1f379a6038e8c0ab0/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:372942e9c44f681bfff377fba25b348801283aa6f3826a00e4195089bda9737a", size = 25762, upload-time = "2025-03-19T18:22:01.055Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/70e23c5570506bb39b56aa4d0f3a4a414e38082ddb33e86a48b546620121/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5bb1115ed38441b2396e732e10ec63d1e68445674f9f5d321f7985eb10e9aeef", size = 24477, upload-time = "2025-03-19T18:22:02.091Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/bfd5a255348902e39243ccc6eba693bce714b891cd3be5603a9bd50c6de5/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b8d084b797f592942208c2040b08e06b82f8832aa6c5e582ba6f1a4a653505b", size = 24970, upload-time = "2025-03-19T18:22:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/3ae074e9acb9e150a4d5d97f341c2064573cd5fe9e5af20ab58bf8c0020a/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:fb6753a9864217d969c4965389d66a476ac978136c03eadf1063b1619c359220", size = 24689, upload-time = "2025-03-19T18:22:04.084Z" }, + { url = "https://files.pythonhosted.org/packages/69/d0/321f74b7d4167a6c59bb7714a6899ba402d9fad611f62573b9d646107320/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9d446c0fc541413d8d2ceea3c1cfb9cbfd57938d6659c113121eca6c245caafe", size = 24404, upload-time = "2025-03-19T18:22:05.232Z" }, +] + +[[package]] +name = "cmeel-octomap" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ab/2fed2dbee13e4b39949591685419f1dbb691295e32a6bbbaf87edc005922/cmeel_octomap-1.10.0.tar.gz", hash = "sha256:bd79d1d17adede534de242e42e13ef0d9f04bdd27daf7d56c57f7c43670c9b05", size = 1694189, upload-time = "2025-01-06T17:57:05.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/22/ea67d35df31ec4bb2ed6e594b173c572c72dbd2a87e96906eac67b4af930/cmeel_octomap-1.10.0-4-py3-none-macosx_14_0_arm64.whl", hash = "sha256:c116eb151920d26ee2b2c1f656cd7526862006739817205f11f9366ab0ef6cb4", size = 639956, upload-time = "2025-01-06T17:56:56.826Z" }, + { url = "https://files.pythonhosted.org/packages/b2/da/07725a8c11224881f536ad252e97a3d9801b48e5e776017d5f00fb39b17f/cmeel_octomap-1.10.0-4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:76cc42553f54bae97584aaf0c7bc33753ff287e2738aa2ecac4820121101dd46", size = 1044402, upload-time = "2025-01-06T17:56:58.553Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/9617b7039afd6d17d3148f6f970d953f5e265d7736f8fdbca09c86e976a0/cmeel_octomap-1.10.0-4-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5fdb04546fff3accac5f8626c3fc15c3b99e94ab887793565e0b92cedaf96468", size = 1105037, upload-time = "2025-01-06T17:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/88c1d1eca1abf2387ee8263ac7e12708c8b1b5b70b46a0bd9f43b485165b/cmeel_octomap-1.10.0-4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:84a7376cfced954bb7e3e347afbd02bdc1c83066b995afbdd0fb1e2d9f57ebec", size = 1108359, upload-time = "2025-01-06T17:57:04.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/59/57b3b38cf7a382855902b9d24266c283c29d977706438e6b7af62df74e2b/cmeel_octomap-1.10.0-5-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:042b4a21b5e5e19ee78a9a7db78e1b06fb8a287c832031788aec0d3fcabbfecd", size = 748832, upload-time = "2025-02-12T11:57:34.252Z" }, + { url = "https://files.pythonhosted.org/packages/55/8b/f5ec7676808a48c0185e216c0da700e34cb13ba233f13a4557a5ec56324a/cmeel_octomap-1.10.0-5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:79c15a0ece5ca3746170088ef2a377dfb3df8326fafde9bdba688852219758b9", size = 706924, upload-time = "2025-02-12T11:57:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/09/50/56de5a4d9f8ca58100146f16f42c4e2fbb49c0957bfe40d3fd2bc910afe4/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_17_i686.whl", hash = "sha256:e2923bf593ebdafed86b6f3890a122c62fbd9cc9f325d60dbecb72b6b60d78fb", size = 1073973, upload-time = "2025-02-12T11:57:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/8dddf5cdd31176288acd85cc8bf0262b7c3de81d5cb2cb33aa6646f44eb1/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9e6f9c826905e8de632e9df8cc20e59ce2eb5d1e0b368d8d4abbbc5c0829c1a", size = 1044533, upload-time = "2025-02-12T11:57:39.672Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/b85bd33bb05c9bb7e87b9ac8401793c12a80a6d594b3ca4bcb5e971a24b7/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b0b54fac180dce4f483afe7029c29cc55f6f2b21be8413e8e2275845b0c204d7", size = 1105199, upload-time = "2025-02-12T11:57:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/fe3360441159974ebdbb4c013a92ad0425d5f8bf414868d5161060e40660/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c8691e665bab7c12b6f51e6c5fbbb83ee6f91dce9d15d9d0387553950e7fb5ee", size = 1092962, upload-time = "2025-02-12T11:57:43.297Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/074166544cc0ce3a5d7844f97dfd13d1b3ec7bff6a6e2cfb18d66a671a7f/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:735c0ad84dacbbcc8c4237f127c57244c236b7d6c7500b5c45a4c225e19daac1", size = 1083321, upload-time = "2025-02-12T11:57:46.121Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a3/b19ea0d30837369091141b248936b0757ee17f58b809007399bad0b398e4/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f86a83f6bd60de290cd327f0374d525328369e76591e3ab2ad1bc0b183678c4", size = 1109207, upload-time = "2025-02-12T11:57:48.709Z" }, +] + +[[package]] +name = "cmeel-qhull" +version = "8.0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/dd/8d0bcfb18771b2ea02bf85dfbbc587c97b274496fb5419b72134eb69430b/cmeel_qhull-8.0.2.1.tar.gz", hash = "sha256:68e8d41d95f61830f2d460af1e4d760f0dbe4d46413d7c736f0ed701153ebe52", size = 1308055, upload-time = "2023-11-17T14:21:06.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b4/d72ebd5e9ee711b68ad466e7bd4c0edcb45b0c2c8a358fdcdb64b092666a/cmeel_qhull-8.0.2.1-0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:39f5183a6e026754c3c043239bac005bf1825240d72e1d8fdf090a0f3ea27307", size = 2804225, upload-time = "2023-11-17T14:15:39.958Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/4bfb8d51a09401cf740e66d10bdb388eacd7c73bae12ef78149cbbc93e83/cmeel_qhull-8.0.2.1-0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:f135c5a4f4c8ed53f061bc86b794aaca2c0c34761c9269c06b71329c9da56f82", size = 2972481, upload-time = "2023-11-17T14:20:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7c/74b5c781cbfc8e4a9bb73b71659cc595bc0163223fd700b18133dbcf2831/cmeel_qhull-8.0.2.1-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:17f519106df79aed9fc5ec92833d4958d132d23021f02a78a9564cdf83a36c7c", size = 3078962, upload-time = "2023-11-17T14:21:00.183Z" }, + { url = "https://files.pythonhosted.org/packages/b4/16/ef7b6201835ba2492753c9c91b266d047b6664507be42ec858e2b24673b5/cmeel_qhull-8.0.2.1-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:c513abafa40e2b8eb7cd3640e3f92d5391fbd6ec0f4182dbf9536934d8a8ea3e", size = 3194917, upload-time = "2023-11-17T14:21:01.879Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ae/200bdf257507e2c95d0656bf02278cd666d49f0a9e2e6d281ea76d7d085c/cmeel_qhull-8.0.2.1-0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:20a69cb34b6250aee1f018412989734c9ddcad6ff66717a7c5516fc19f55d5ff", size = 3290068, upload-time = "2023-11-17T14:21:03.828Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/de3fa6091ef58ab40f02653e777c8943acf7cec486184d6007885123571d/cmeel_qhull-8.0.2.1-1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b5d47b113c1cb8f519bc813cf015d0d01f8ce5b08912733a24a6018f7caa6e96", size = 2902499, upload-time = "2025-02-12T11:51:16.999Z" }, + { url = "https://files.pythonhosted.org/packages/05/0c/5e5d9a033c683eb272508ccf560c03ac6bf5d397b038fe05f896a2283eaf/cmeel_qhull-8.0.2.1-1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:33a0169f4ee37d093c450195b0ef73d4fe0d9d62abb7899ebe79f778b36e1f36", size = 2773563, upload-time = "2025-02-12T11:51:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/52/9b/00c73069348e60fbbdf6a5a10de046083f7d1ad36844958bbf12163ac688/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_17_i686.whl", hash = "sha256:a577e76ac94d128f2966b137ead9f088749513df63749728e2b588f4564b7fdf", size = 3228684, upload-time = "2025-02-12T11:51:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/81b8c88b444935a64d8c83b41e662f696c36dd5937c3ca687113ac4778d0/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fd0b2d4ce749b102c3cdead4588249befd34f1a660628f6bfc090ce942925aac", size = 3156051, upload-time = "2025-02-12T11:51:24.594Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c1/44874cd8bfc1e3f7cb15678c836c7a1d5537f34f5a727a0207e01f395598/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2371a7c80a14f3e874876359ae3e3094861f081fcdd7a03987c3e880d14e07b9", size = 3262508, upload-time = "2025-02-12T11:51:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/54/0e/425d9ce1f2a831025d39fa5b6479b856bd4d73614c9caa690ac72bbfca04/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:197c14c2006dbeba8f5a5771700a7afea72c1a441aab7cdeaaf10b4ed8c1137d", size = 3172646, upload-time = "2025-02-12T11:51:28.967Z" }, + { url = "https://files.pythonhosted.org/packages/00/c1/e973e287a7d793911b8e6497b17586e601a678f2379ba2c615f72bd76480/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:886d1be24b31842286ae42755af5c312a43a4199632826e4110185ec36dc5c6a", size = 3530837, upload-time = "2025-02-12T11:51:31.651Z" }, + { url = "https://files.pythonhosted.org/packages/fd/65/c6cd54f04b5fcaa4ec52f5b57692c1dcef812ff9ee86545e5607369d365e/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a49ce7f8492c9a8b49f930e34cce75b5e9b9843b015033dd0a25421441159fc", size = 3301908, upload-time = "2025-02-12T11:51:34.53Z" }, +] + +[[package]] +name = "cmeel-tinyxml2" +version = "11.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/96/4311533fee0a364bb605b585762f04c249f47857b33548a8ea837a7eb860/cmeel_tinyxml2-11.0.0.tar.gz", hash = "sha256:85d9c7680b3369af4c6b40a0dce70bbd84aa67832755622e57eb260cd95abe40", size = 645900, upload-time = "2026-05-21T11:49:32.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/f0/90c1640c53b623359d75ab1c70bdf19dc0afe82722bc5df57d09f8eaf83a/cmeel_tinyxml2-11.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b0bd974e549b8c444626671a8e645897603ebf5225734cbe04a9dd3461477754", size = 111719, upload-time = "2026-05-21T11:49:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/56/40/166447150a31bc3b794ffb493d5a634f67ffbc75dd8b4c46373701b7ef15/cmeel_tinyxml2-11.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a1406f408262c37ae7c4566b1d67801c4b10c4980903fb1ef0ba45fa4407072", size = 109146, upload-time = "2026-05-21T11:49:27.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ca/3cc665afe2d76999f15454bb3b2f7c05f0088ad7de35718648291a536fd9/cmeel_tinyxml2-11.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6f830007917c3e36f26b27d170ce84a619a62f46104d3cce435dff0125dd665f", size = 157109, upload-time = "2026-05-21T11:49:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/87/4e/dcc0d9756d93be734d824e2a570cc9ac68909a1d7d3b6fc87c2fb32726c0/cmeel_tinyxml2-11.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:18674156bd41f3993dc1d5199da04fa496674358daa6588090fb9f86c71917b0", size = 148825, upload-time = "2026-05-21T11:49:31.035Z" }, +] + +[[package]] +name = "cmeel-urdfdom" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-console-bridge" }, + { name = "cmeel-tinyxml2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/75/4e8aff079e98582aeeb8e752805081da0c2dea405e79bafeefb555defe9f/cmeel_urdfdom-6.0.0.tar.gz", hash = "sha256:65c0fdc6021300fc55b2d0c03ab64dedc328034a74e40498e671bc894bb1dcf7", size = 303688, upload-time = "2026-05-21T12:08:56.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/40/51ba667135f01631179eee1614557193f8453740f248302d1b8b7f9f693e/cmeel_urdfdom-6.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:53d55cebb137a6e4dac6c16fa53f2dc2b7b9b5cda644bd1637a5bb849cd96e52", size = 381501, upload-time = "2026-05-21T12:08:48.758Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/2b49a8c940fa75abc13df9842c14e577e6a82d5854b6d52597ce3bb04894/cmeel_urdfdom-6.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0ef424735bd30f4afa4d1b4ddca9b297498c43005ddd775c080e55f62e9e0466", size = 377159, upload-time = "2026-05-21T12:08:50.485Z" }, + { url = "https://files.pythonhosted.org/packages/db/ac/0efde3a48220b55707bafb6d2e2dcca562f99dcd5c2c15311f7696eeacce/cmeel_urdfdom-6.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0436f5230f1484c8e583284ef48c7291b230ada3dc5fb2937941f582e72409ec", size = 506000, upload-time = "2026-05-21T12:08:52.273Z" }, + { url = "https://files.pythonhosted.org/packages/32/d4/dfd617e598100e4e53ae3d228a968facff80bae53038fb18e2dccb1ab03a/cmeel_urdfdom-6.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7ab1be680a8ec866d5422c617b641d1f0e38774061df28b8b426fb26edce6337", size = 530049, upload-time = "2026-05-21T12:08:54.224Z" }, +] + +[[package]] +name = "cmeel-zlib" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/85/9c6b77d5c49b363b17ba974271d58730bd26cbe00b1c576596339bb624ea/cmeel_zlib-1.3.2.tar.gz", hash = "sha256:6e31f2956c334de9a7e19a4e4f8eed030ed8c8016c841ea57226ce8bed443712", size = 3200, upload-time = "2026-05-20T16:24:37.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e7/c60533fb6f638fe92d56b79edbcff70ed74a7f19b3ace981532efab5c3dd/cmeel_zlib-1.3.2-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:88a89882340391aa263ed1f7448fe0177e6fb9b93740cad381228437879f16a9", size = 1035696, upload-time = "2026-05-20T16:24:28.538Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/dfcc8b9c2989e7342aaac1781d147b06b31197337d1968029216be26ee43/cmeel_zlib-1.3.2-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:94a2f1adcc811617eab849b5771e608fa20e2fc44fb8cbd2e56a3e4d496461c1", size = 957505, upload-time = "2026-05-20T16:24:30.151Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e9/6a3d6732e23fcf7072973f9ca8251eaef3e6738e46d6846064dd92e13331/cmeel_zlib-1.3.2-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3b07fb7e28aaa917e6accb59a29dd0f5c1d272d42988f32ba2c232f5f455be4c", size = 1055975, upload-time = "2026-05-20T16:24:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/70/a4/d345605b6f8c9d665baa1dd2f839ad6aee0d771e174be12c81fdd53067ed/cmeel_zlib-1.3.2-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4658000c5531273d14ca8f0250abcd2a2ad85b336f10a273a77ecb38611a5f5a", size = 1052274, upload-time = "2026-05-20T16:24:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/83737c38cc106e1b4959393da7744d5180b9ab5eac0369fc6ba0ea6c6428/cmeel_zlib-1.3.2-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:06cbf60a361ca18e8a4c46a238149cebcaf955047a60830705e130aa397b5116", size = 1057248, upload-time = "2026-05-20T16:24:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/b378182b0f33ed9e9c091b3d1e8d86a6f2d69add107087b2e09adcb12137/cmeel_zlib-1.3.2-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2a3425a65a0adf0ddbb48d41204a0d9c13197bd693f7821d1450e8a969d77352", size = 1055409, upload-time = "2026-05-20T16:24:36.219Z" }, +] + +[[package]] +name = "coal" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-assimp" }, + { name = "cmeel-boost" }, + { name = "cmeel-octomap" }, + { name = "cmeel-qhull" }, + { name = "eigenpy" }, + { name = "libcoal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/14/8cb072a3e5fb85cac4d8d1234351ca3eb4ecf100b87901ab912ec7e5135e/coal-3.0.3.tar.gz", hash = "sha256:18660bde4021af496343646fa0a0d1bcf0be392f432641e772dc423be5075f64", size = 1464421, upload-time = "2026-05-21T12:30:20.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/4d/6af55fff20a0705f54ea313c2fe64b090e3e60e50fdcb5bfdb430a22e391/coal-3.0.3-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1e59370db33b4fb0e23da8f953b300b3eec591624803e3143d447be21f530b87", size = 1611149, upload-time = "2026-05-21T12:29:52.062Z" }, + { url = "https://files.pythonhosted.org/packages/9a/78/3323984a61b63515e1951e2f697d4250a55b54788bc65496e2824989d969/coal-3.0.3-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:22572a8f877488ab987f868ffd362c08f923470be6b1e4f9c86d260f110405fc", size = 1505271, upload-time = "2026-05-21T12:29:53.871Z" }, + { url = "https://files.pythonhosted.org/packages/1d/29/65550bafd2639b8152e6a0e63603e74f657761fc1dc5daa4a305b5ea9922/coal-3.0.3-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b8aafdf317161ba73858ed1a3e8e673c5a82bd04cef82a3b7ba953d6a8d1007d", size = 2218087, upload-time = "2026-05-21T12:29:55.361Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/e5859f9c03c6353e50ac8df218a6b9efb8de90f3fa1f3bd571bc6a3242cd/coal-3.0.3-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2c9a62cd52868ce295623e34e664084ee87b9bb6314df913952d288cbe8b3fe6", size = 2215823, upload-time = "2026-05-21T12:29:57.227Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/1c634b366c7ba991f3f11b73bfff5c8cfee0ee32911347f37a256bc1724c/coal-3.0.3-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fd9c9bb7d4de17afda5e59802fddddee4149bdf72312cd60d68403b2d5265e9b", size = 1626797, upload-time = "2026-05-21T12:29:59.079Z" }, + { url = "https://files.pythonhosted.org/packages/33/5a/6c9836db4747010fc31543336a6b7f9aea04b1a58b8fe09611daf5e965f4/coal-3.0.3-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d3c157a72d3c79a99bd6b59479ab5ca0efeed22b74e5d8e66dda827ce76e18e", size = 1516483, upload-time = "2026-05-21T12:30:00.967Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/8c7c7e83c83acdc5af8c553a2ca0b1b335132dccff4732edd6acd90a5a16/coal-3.0.3-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f9736086902db07b3b38b04917e7d3c9291d12340e5248c06b327ce6a6e2de49", size = 2190068, upload-time = "2026-05-21T12:30:02.692Z" }, + { url = "https://files.pythonhosted.org/packages/19/f1/25368a2fb63a18283b6732e2cdbe15bfb19df26b76f3721fd79729def54a/coal-3.0.3-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b98f45b92af8f6608a97d47f05abab861af13ba54a47c82dfc22c9f4e7754a18", size = 2201644, upload-time = "2026-05-21T12:30:04.333Z" }, + { url = "https://files.pythonhosted.org/packages/fb/37/69680aa3b0d6f1cf6309177326d625fe8afe49b440952ee36735c4e6be2b/coal-3.0.3-0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:cf489028d1123293fe4b0598fdd21ab2c0a7c6f69d8e61a0a849712b47aba885", size = 1626799, upload-time = "2026-05-21T12:30:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/7d/72/c3b7d84682868252e43f0dc3dfcec5a5d754a383fb20db17863a55d10f2b/coal-3.0.3-0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dc81cf507301d145b2c0085072effb3744bf3034669092f7d95c49d7318aba5", size = 1516481, upload-time = "2026-05-21T12:30:07.985Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d9/f0dd708b923368e4628fb760fe2ce2a3f2cd026e2effbad186c076fadb9c/coal-3.0.3-0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:691da7cd15683d23a28cdea81906a6e614cffbeab92523494ea493ede5d433e8", size = 2190073, upload-time = "2026-05-21T12:30:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/51/76/5ca02caa98c238bf336ce0387464669c29b6f57c3daab441f047a9e65cc1/coal-3.0.3-0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80cba8b0c3166a024deacfc801b0c6a06337d230af434c379f693887d5c4e458", size = 2201651, upload-time = "2026-05-21T12:30:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/aa/9b0a3560f86c586aa2ddde57c08f7b6d4c9dc622e82104300da5a1781e25/coal-3.0.3-0-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:5888efaa97a1d6160e07503c5463fa55180120d7219aec1cbe0a1efe1d84449b", size = 1628179, upload-time = "2026-05-21T12:30:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/a2/f90b450da22e69922c3ed36bb8e84376fac56a95d1c864ab477a5a77a951/coal-3.0.3-0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dfc85277f55495da5a15cf9a1d86530004c6225986803935cab6e6c90638002f", size = 1516891, upload-time = "2026-05-21T12:30:15.763Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/0172d8558f1231c9542b875854101616dfd4227bd540ad095e257596e8bf/coal-3.0.3-0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e3f46e2030a4a6f91704d5c8e310c80032e979e1d4bb491265a8beb4358a7998", size = 2197024, upload-time = "2026-05-21T12:30:17.562Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f2/f79c90ec7289f5f80481a1b60bde05a385d247f4a6269fc64b04e8714054/coal-3.0.3-0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4553a8e088fddf46c48229686e9b66df7e38d09bbd26fa32c411fb5cf86ac3f0", size = 2204048, upload-time = "2026-05-21T12:30:19.398Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "configargparse" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "dash" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "janus" }, + { name = "mcp" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/3c/0608ea83920ddbd27de3aeb60d48e56efb3390c2cfb44dabfd8dc25c0ba2/dash-4.3.0.tar.gz", hash = "sha256:1ec96641d5e3c06b7dfffad8241e345533d70327d96f722a8e13b827c287341b", size = 8095642, upload-time = "2026-06-19T14:56:03.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/ad/0a230dc6cd85bb38e7fe6acc9fbaeb19ddf16ababcb54038ae81128da591/dash-4.3.0-py3-none-any.whl", hash = "sha256:cbbe79a2bb1a5e46fbbbddc33f9f558419442c2d55cd12526078f489b5801cc0", size = 8445443, upload-time = "2026-06-19T14:55:59.024Z" }, +] + +[[package]] +name = "dbus-fast" +version = "5.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/84/dfb014de75a3a854dccaae1cce8f840e4312e3efc781768eedd60d25d9ef/dbus_fast-5.0.22-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846f9a6602b4383f989201f7851459fb225a8912cd24b38e63894748545c3040", size = 838836, upload-time = "2026-06-05T18:55:51.51Z" }, + { url = "https://files.pythonhosted.org/packages/70/c2/be41bcc678e97092d44ba22d09ce687f76c955b3367a7e6863377b1cfea5/dbus_fast-5.0.22-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886b43446b6fdc3986befbbb88db1365b14e49dd0a7edf84c2c67ac66c7160a4", size = 883163, upload-time = "2026-06-05T18:55:53Z" }, + { url = "https://files.pythonhosted.org/packages/17/cf/336c08f88fdd813a39fc1603a10a15aec67115e08a895e7c9840df54d4d7/dbus_fast-5.0.22-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a699fca957acc845ddb12b47f741dba23ce147fdb93583e0c7e7bad3e9b2355", size = 886852, upload-time = "2026-06-05T18:55:54.434Z" }, + { url = "https://files.pythonhosted.org/packages/04/f1/7c1aa53f25252a2317f21ad6e8eaa125246d2585ea4611f3f10a6feaeeb1/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a5b05fd4973862042e5bee2c5e8c5a15297e0b33a975bf25b44becf7bcb3618", size = 846497, upload-time = "2026-06-05T18:55:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/4c/32/a981ef2305f1bf41e538e02fa0cd69614f9043fd00ca965bf3044416c79b/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6c4dae5292a7924ec062815c34b49043d8386cd22e165f9fb4012de00997cdf1", size = 882275, upload-time = "2026-06-05T18:55:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/78800a172f4ca32e19a70a36e175f54831f33a497c9644f3a3fa4dce01ea/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7d90a52be79acbaef257f3a81d5b9b9dec40f1bad29429ac5c7802684fb9b84", size = 890566, upload-time = "2026-06-05T18:55:59.407Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/233b0bc13919474f70320bf389cb81ce02811956d6cf86c45e84679b63c3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca", size = 799464, upload-time = "2026-06-05T18:56:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/68/e9/77bc23a6f5aebfb8f2c34489795e8517aed7eca31738438e1a4c4a4891d3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", size = 852687, upload-time = "2026-06-05T18:56:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/56769d0936d1273d1801ef574ec426ccb3f61f4b0a7a0eeb9eb2b8ccafa5/dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c", size = 833814, upload-time = "2026-06-05T18:56:05.87Z" }, + { url = "https://files.pythonhosted.org/packages/06/ca/964f0d39a3be03b12a98f39519d34ad95b74360c7e3adf4cd4907dc25fd6/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b013437b66dc22b8d9aca5e0b0d46bf1980208a143409469fe482d9684a2a717", size = 806891, upload-time = "2026-06-05T18:56:07.623Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/57fe6ab509ad9da2e190498fa9c37f868e38ac521d940a9edb9ba6b6c657/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:855f15b7f7805171da2b82de1c317d01cfbb9fb8ac61fcc1e8dec54d8c69fab7", size = 830867, upload-time = "2026-06-05T18:56:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/fa81a6685c763ea488ad93228cb6e036adc9af6a560f4c31643691f4cfd8/dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5", size = 798472, upload-time = "2026-06-05T18:56:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/6b272e6df60be1aa4d575aa30220175a52c002a649c951d9950bfa3a72d6/dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb", size = 850312, upload-time = "2026-06-05T18:56:16.36Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/9045c3595ddcd4069e6b5d051df06bf11a2b022592f721c9acea1e0e4d22/dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08", size = 828366, upload-time = "2026-06-05T18:56:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/a7/78/ca6881442b8fa29edbe6d99bec4b535b0b2e2f423075d015ff5b719c4e2c/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979", size = 806036, upload-time = "2026-06-05T18:56:19.501Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c6/458728eb1caa26171e6a8ae1d0d99bd29aaeac67ad7824bbd95d7f854a41/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec", size = 828353, upload-time = "2026-06-05T18:56:21.346Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/830b1569264780210d44898e5b0d95cffe2830b952c2ee21ea481274cd81/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84", size = 857743, upload-time = "2026-06-05T18:56:22.9Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" }, + { url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" }, + { url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" }, +] + +[[package]] +name = "dimos" +version = "0.0.12" +source = { editable = "../../" } +dependencies = [ + { name = "annotation-protocol" }, + { name = "bleak" }, + { name = "cryptography" }, + { name = "dimos-lcm" }, + { name = "dimos-viewer" }, + { name = "llvmlite" }, + { name = "lz4" }, + { name = "numba" }, + { name = "numpy" }, + { name = "open3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "open3d-unofficial-arm", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "opencv-python" }, + { name = "pin" }, + { name = "plotext" }, + { name = "plum-dispatch" }, + { name = "protobuf" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "pyturbojpeg" }, + { name = "reactivex" }, + { name = "rerun-sdk" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sortedcontainers" }, + { name = "sqlite-vec" }, + { name = "structlog" }, + { name = "terminaltexteffects" }, + { name = "textual" }, + { name = "textual-serve" }, + { name = "toolz" }, + { name = "typer" }, + { name = "websocket-client" }, +] + +[package.metadata] +requires-dist = [ + { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'" }, + { name = "annotation-protocol", specifier = ">=1.4.0" }, + { name = "bleak", specifier = ">=3.0.2" }, + { name = "can-motor-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'", specifier = ">=0.0.2" }, + { name = "catkin-pkg", marker = "extra == 'grasp'", specifier = ">=1.1.0" }, + { name = "chromadb", marker = "extra == 'perception'", specifier = ">=1.0.0" }, + { name = "cryptography", specifier = ">=46.0.5" }, + { name = "cupy-cuda12x", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = "==13.6.0" }, + { name = "cyclonedds", marker = "extra == 'dds'", specifier = ">=0.10.5" }, + { name = "cyclonedds", marker = "extra == 'unitree-dds'", specifier = ">=0.10.5" }, + { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "grasp", "manipulation", "misc", "perception", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, + { name = "dimos", extras = ["agents", "web", "perception", "visualization"], marker = "extra == 'base'" }, + { name = "dimos", extras = ["base", "mapping"], marker = "extra == 'unitree'" }, + { name = "dimos", extras = ["unitree"], marker = "extra == 'unitree-dds'" }, + { name = "dimos-lcm", specifier = ">=0.1.3" }, + { name = "dimos-viewer", specifier = "==0.32.0a1" }, + { name = "dimos-viewer", marker = "extra == 'visualization'", specifier = "==0.32.0a1" }, + { name = "drake", marker = "platform_machine != 'aarch64' and sys_platform == 'darwin' and extra == 'manipulation'", specifier = "==1.45.0" }, + { name = "drake", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and extra == 'manipulation'", specifier = ">=1.40.0" }, + { name = "edgetam-dimos", marker = "extra == 'misc'" }, + { name = "einops", marker = "extra == 'perception'", specifier = ">=0.8.1" }, + { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.115.6" }, + { name = "faster-whisper", marker = "extra == 'agents'", specifier = ">=1.0.0" }, + { name = "ffmpeg-python", marker = "extra == 'web'" }, + { name = "gdown", marker = "extra == 'misc'", specifier = ">=5.2.2" }, + { name = "googlemaps", marker = "extra == 'misc'", specifier = ">=4.10.0" }, + { name = "gtsam-extended", marker = "extra == 'mapping'", specifier = ">=4.3a1.post1" }, + { name = "hydra-core", marker = "extra == 'perception'", specifier = ">=1.3.0" }, + { name = "ipykernel", marker = "extra == 'misc'" }, + { name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.6" }, + { name = "langchain", marker = "extra == 'agents'", specifier = ">=1.2.3,<2" }, + { name = "langchain-core", marker = "extra == 'agents'", specifier = ">=1.2.22,<2" }, + { name = "langchain-huggingface", marker = "extra == 'agents'", specifier = ">=1,<2" }, + { name = "langchain-ollama", marker = "extra == 'agents'", specifier = ">=1,<2" }, + { name = "langchain-openai", marker = "extra == 'agents'", specifier = ">=1,<2" }, + { name = "lap", marker = "extra == 'perception'", specifier = ">=0.5.12" }, + { name = "llvmlite", specifier = ">=0.42.0" }, + { name = "lz4", specifier = ">=4.4.5" }, + { name = "matplotlib", marker = "extra == 'grasp'", specifier = ">=3.7.1" }, + { name = "matplotlib", marker = "extra == 'manipulation'", specifier = ">=3.7.1" }, + { name = "mcap", marker = "extra == 'unitree-dds'", specifier = ">=1.2.0" }, + { name = "moondream", marker = "extra == 'perception'" }, + { name = "mujoco", marker = "extra == 'sim'", specifier = ">=3.3.4" }, + { name = "numba", specifier = ">=0.60.0" }, + { name = "numpy", specifier = ">=1.26.4" }, + { name = "ollama", marker = "extra == 'agents'", specifier = ">=0.6.0" }, + { name = "omegaconf", marker = "extra == 'perception'", specifier = ">=2.3.0" }, + { name = "onnxruntime", marker = "extra == 'cpu'" }, + { name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = ">=1.17.1" }, + { name = "open-clip-torch", marker = "extra == 'misc'", specifier = "==3.2.0" }, + { name = "open3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=0.18.0" }, + { name = "open3d-unofficial-arm", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = ">=0.19.0.post9" }, + { name = "openai", marker = "extra == 'agents'" }, + { name = "opencv-contrib-python", marker = "extra == 'apriltag'", specifier = "==4.10.0.84" }, + { name = "opencv-python" }, + { name = "pillow", marker = "extra == 'perception'" }, + { name = "pin", specifier = ">=3.3.0" }, + { name = "pin-pink", marker = "extra == 'manipulation'", specifier = ">=4.2.0" }, + { name = "piper-sdk", marker = "extra == 'manipulation'" }, + { name = "playground", marker = "extra == 'sim'", specifier = ">=0.0.5" }, + { name = "plotext", specifier = "==5.3.2" }, + { name = "plum-dispatch", specifier = "==2.5.7" }, + { name = "portal", marker = "extra == 'misc'" }, + { name = "protobuf", specifier = ">=6.33.5,<7" }, + { name = "psutil", specifier = ">=7.0.0" }, + { name = "pycollada", marker = "extra == 'manipulation'" }, + { name = "pydantic" }, + { name = "pydantic-settings", specifier = ">=2.11.0,<3" }, + { name = "pygame", marker = "extra == 'sim'", specifier = ">=2.6.1" }, + { name = "pymavlink", marker = "extra == 'drone'" }, + { name = "pyrealsense2-extended", marker = "sys_platform != 'darwin' and extra == 'manipulation'" }, + { name = "python-dotenv" }, + { name = "python-multipart", marker = "extra == 'misc'", specifier = ">=0.0.27" }, + { name = "pytorch-ignite", marker = "extra == 'grasp'" }, + { name = "pyturbojpeg", specifier = "==1.8.2" }, + { name = "pyyaml", marker = "extra == 'manipulation'", specifier = ">=6.0" }, + { name = "qpsolvers", extras = ["proxqp"], marker = "extra == 'manipulation'", specifier = ">=4.12.0" }, + { name = "reactivex" }, + { name = "reportlab", marker = "extra == 'apriltag'", specifier = ">=4.5.0" }, + { name = "rerun-sdk", specifier = "==0.32.0a1" }, + { name = "rerun-sdk", marker = "extra == 'visualization'", specifier = "==0.32.0a1" }, + { name = "roboplan", extras = ["manipulation"], marker = "extra == 'manipulation'", specifier = ">=0.0.100" }, + { name = "scipy", specifier = ">=1.15.1" }, + { name = "sortedcontainers", specifier = "==2.4.0" }, + { name = "sounddevice", marker = "extra == 'agents'" }, + { name = "soundfile", marker = "extra == 'web'" }, + { name = "sqlite-vec", specifier = ">=0.1.6" }, + { name = "sse-starlette", marker = "extra == 'web'", specifier = ">=2.2.1" }, + { name = "structlog", specifier = ">=25.5.0,<26" }, + { name = "tensorboard", marker = "extra == 'misc'", specifier = "==2.20.0" }, + { name = "terminaltexteffects", specifier = "==0.12.2" }, + { name = "textual", specifier = "==3.7.1" }, + { name = "textual-serve", specifier = ">=1.1.1,<2" }, + { name = "timm", marker = "extra == 'misc'", specifier = ">=1.0.15" }, + { name = "toolz", specifier = ">=1.1.0" }, + { name = "torch", marker = "extra == 'grasp'" }, + { name = "torchreid", marker = "extra == 'misc'", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], marker = "extra == 'perception'", specifier = ">=4.53.0,<4.54" }, + { name = "trimesh", marker = "extra == 'manipulation'" }, + { name = "typer", specifier = ">=0.19.2,<1" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0" }, + { name = "ultralytics", marker = "extra == 'perception'", specifier = ">=8.3.70" }, + { name = "unitree-sdk2py-dimos", marker = "extra == 'unitree-dds'", specifier = ">=1.0.2" }, + { name = "unitree-webrtc-connect", marker = "extra == 'unitree'", specifier = ">=2.1.2" }, + { name = "uvicorn", marker = "extra == 'web'", specifier = ">=0.34.0" }, + { name = "vgn", marker = "extra == 'grasp'", git = "https://github.com/ethz-asl/vgn.git?rev=d7af0622433f52ae88ebe81533f12b46b33e951a" }, + { name = "viser", extras = ["urdf"], marker = "extra == 'manipulation'", specifier = ">=1.0.29" }, + { name = "websocket-client", specifier = ">=1.8" }, + { name = "xacro", marker = "extra == 'manipulation'" }, + { name = "xarm-python-sdk", marker = "extra == 'manipulation'", specifier = ">=1.17.0" }, + { name = "xarm-python-sdk", marker = "extra == 'misc'", specifier = ">=1.17.0" }, +] +provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "grasp", "cpu", "cuda", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] + +[package.metadata.requires-dev] +autofix = [{ name = "ruff", specifier = "==0.14.3" }] +lint = [ + { name = "aiortc", specifier = ">=1.14.0" }, + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "ipython" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "moondream" }, + { name = "mypy", specifier = "==1.19.0" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "openai-whisper" }, + { name = "pandas-stubs", specifier = ">=2.3.2.250926,<3" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "python-can", specifier = ">=4" }, + { name = "python-socketio", specifier = ">=5.16.1" }, + { name = "ruff", specifier = "==0.14.3" }, + { name = "sounddevice", specifier = ">=0.5.5" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "trimesh", specifier = ">=4.12" }, + { name = "types-pyaudio" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915,<7" }, + { name = "types-reportlab", specifier = ">=4.5.0" }, + { name = "types-requests", specifier = ">=2.32.4.20260107,<3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "watchdog", specifier = ">=3.0.0" }, + { name = "xacro" }, +] +project-deps = [ + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "moondream" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "xacro" }, +] +tests = [ + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "coverage", specifier = ">=7.0" }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu"] }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "maturin", specifier = ">=1.7" }, + { name = "md-babel-py", specifier = ">=1.2.0" }, + { name = "moondream" }, + { name = "mujoco", specifier = ">=3.3.4" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "py-spy" }, + { name = "pygame", specifier = ">=2.6.1" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "pytest-env", specifier = "==1.1.5" }, + { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, + { name = "pytest-mock", specifier = "==3.15.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, + { name = "pytest-xdist", specifier = ">=3.5.0" }, + { name = "python-can", specifier = ">=4" }, + { name = "python-lsp-ruff", specifier = "==2.3.0" }, + { name = "python-lsp-server", extras = ["all"], specifier = "==1.14.0" }, + { name = "requests-mock", specifier = "==1.12.1" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "unitree-webrtc-connect", specifier = ">=2.1.2" }, + { name = "viser", extras = ["urdf"], marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=1.0.29" }, + { name = "watchdog", specifier = ">=3.0.0" }, + { name = "xacro" }, +] +tests-self-hosted = [ + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "coverage", specifier = ">=7.0" }, + { name = "dimos", extras = ["agents", "perception", "manipulation", "sim", "unitree", "misc"] }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu"] }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "maturin", specifier = ">=1.7" }, + { name = "mcap", specifier = ">=1.2.0" }, + { name = "md-babel-py", specifier = ">=1.2.0" }, + { name = "moondream" }, + { name = "mujoco", specifier = ">=3.3.4" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "py-spy" }, + { name = "pybind11", specifier = ">=2.12" }, + { name = "pygame", specifier = ">=2.6.1" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "pytest-env", specifier = "==1.1.5" }, + { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, + { name = "pytest-mock", specifier = "==3.15.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, + { name = "pytest-xdist", specifier = ">=3.5.0" }, + { name = "python-can", specifier = ">=4" }, + { name = "python-lsp-ruff", specifier = "==2.3.0" }, + { name = "python-lsp-server", extras = ["all"], specifier = "==1.14.0" }, + { name = "requests-mock", specifier = "==1.12.1" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "unitree-webrtc-connect", specifier = ">=2.1.2" }, + { name = "viser", extras = ["urdf"], marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=1.0.29" }, + { name = "watchdog", specifier = ">=3.0.0" }, + { name = "xacro" }, +] + +[[package]] +name = "dimos-gpd-grasp-demo" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dimos" }, + { name = "gpd" }, +] + +[package.metadata] +requires-dist = [ + { name = "dimos", editable = "../../" }, + { name = "gpd", git = "https://github.com/TomCC7/gpd.git?rev=c088d8ae2f7965b067e9a12b3c0dacdbe9da924a" }, +] + +[[package]] +name = "dimos-lcm" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "foxglove-websocket" }, + { name = "lcm-dimos-fork" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b2/4cdd2bce665ab633313b5d371e0a63fcae2cf77a934a2a9bf820db88a540/dimos_lcm-0.1.3.tar.gz", hash = "sha256:5f7dbd3055f299823bc0e450c59583ad5a2d093c182deec8a86073853881bb09", size = 405688, upload-time = "2026-06-03T07:20:20.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/19/2d8babf544993508359922ea59db2280d42efa0d5a80d4d0cae22ce40d67/dimos_lcm-0.1.3-py3-none-any.whl", hash = "sha256:63317225a0b4ab0f05e4b656f3f78ac9ba4e914998da2c689ca839cbbd83d57d", size = 1714087, upload-time = "2026-06-03T07:20:22.689Z" }, +] + +[[package]] +name = "dimos-viewer" +version = "0.32.0a1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/37/394f74ad8981c4666d9014189f3fb154e7f796c408d770d94f52ed4b1ccb/dimos_viewer-0.32.0a1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:86f2dc0f5db732b28ce5d9f13749a12b26f3b3c891e68bae8fcb4c065f08ddf5", size = 38326731, upload-time = "2026-05-19T14:55:05.95Z" }, + { url = "https://files.pythonhosted.org/packages/35/0d/677674fd9a8ac73689642cd7c4bb1b177df9d096f39a74dadd1420ffc221/dimos_viewer-0.32.0a1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f22176cea73d6e898230432de19ca07772a7c1293171ad86dae0b7e54e1fbbf5", size = 42253900, upload-time = "2026-05-19T14:55:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/0c32f2a5e0908dcab3d8b652704d68124cbe2d74ca418561e503fa216cff/dimos_viewer-0.32.0a1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f4a4b6e1aa83b50421a7133b22c27c0924b2f984a242d30273087f66928af5d8", size = 44794937, upload-time = "2026-05-19T14:55:12.888Z" }, + { url = "https://files.pythonhosted.org/packages/3f/51/efcf47f154b5940d09d6e480f3f75f00a927396d9be1f5b2523b1ff9d62e/dimos_viewer-0.32.0a1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4cc88e26567955ce964a250c7250cd3d52e99f330d892420699f0580de8c4f3", size = 38326735, upload-time = "2026-05-19T14:55:16.477Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3e/7564e12f24f4678ba5e311a538eda0a2e46add9afcd58d08984e66bc280c/dimos_viewer-0.32.0a1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:68354192271a201a71491245a7b444b0e4047b74c9d2496a395010356917c0c3", size = 42253910, upload-time = "2026-05-19T14:55:20.232Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4b/d97f13e3585582c79037cd24952ac99de593a30c300ddcc8c83d9cab1128/dimos_viewer-0.32.0a1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ebdd64ce20d73e9d6e0256ef099a3d544c14b9b227aa005ba8c362052889b3c4", size = 44794790, upload-time = "2026-05-19T14:55:24.211Z" }, +] + +[[package]] +name = "eigenpy" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/04/15aadcaf4141c791217724c3edc103cd66c5444987cb897a2e7112bb94ee/eigenpy-3.13.0.tar.gz", hash = "sha256:49d3cb46b85696baf5445ac707b48aade07c21579c188dfc1c8ad2f92e05fab5", size = 229750, upload-time = "2026-05-21T11:01:28.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/fe/20fccfbb855a2fc94cf1b05eac2634abd77f47126c3e0d3e0353665b2f2b/eigenpy-3.13.0-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbcf85629d25d822db935ab9d5415218cff0717d620f87e1f3e7b86f1357cfd4", size = 5440358, upload-time = "2026-05-21T11:00:53.805Z" }, + { url = "https://files.pythonhosted.org/packages/ee/52/fa7433226e3af2437b70bb2d8357173cae74abe3ead4240c9b136a794043/eigenpy-3.13.0-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e4d51aa0980fd7b4d47acc9c2ac9c4e9bbb6a6d1e2149ba2426292f2972276e", size = 4251073, upload-time = "2026-05-21T11:00:55.921Z" }, + { url = "https://files.pythonhosted.org/packages/66/1e/6c3e45fbd37812faf9343c70a7661f376c123259f9e43e6f2b4fc4211f5c/eigenpy-3.13.0-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:aabe2f552e6ecae5381446bc03ba612469a10a06dfc471bbf2f194e0edcd6974", size = 6228917, upload-time = "2026-05-21T11:00:58.128Z" }, + { url = "https://files.pythonhosted.org/packages/83/a3/79326cff7ab8a55cd9024eee4ddf3082ac9f7be2c978fc5f5b056affc968/eigenpy-3.13.0-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:bca70df5845a3598f1ddd77a3ce46ef5eb28228c85e362e4f75115c6cb80dcff", size = 6064372, upload-time = "2026-05-21T11:01:00.376Z" }, + { url = "https://files.pythonhosted.org/packages/07/03/bfc7a04ef67dd330f7cee8c984d6d101457a19dd9612e5a15306417b62c2/eigenpy-3.13.0-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2203a3073e54a9d55b9766b2d8fdaa6eae3e733742ac5862ca8afbd4e55021ae", size = 5485488, upload-time = "2026-05-21T11:01:02.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/61/cb30323bce5cec4a5a496e41972e12110638dbdba94f2d23814dd82f0eb4/eigenpy-3.13.0-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a844167b3dcb6157cd782fe8cd0396153548eccdcfe81e95803f2499f306ed18", size = 4261367, upload-time = "2026-05-21T11:01:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/fb/90/28a30554ff1a6794a2ef91ffc021737796441d617acd649efecf6b307cce/eigenpy-3.13.0-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5524d36fe0705d0097a42e0d18e4eb3f49dc1e28be301200ab7af7d58b9ae5f", size = 6230829, upload-time = "2026-05-21T11:01:07.631Z" }, + { url = "https://files.pythonhosted.org/packages/5f/39/f3f15daffdcecc31612135ae0f77603ce6ba88c9fcc1c6a5f1e53b2eb0f9/eigenpy-3.13.0-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cde8ed4080da67e0c037ba1453c8d33c74465714ce5196781b640dcc398579a5", size = 6068314, upload-time = "2026-05-21T11:01:10.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/0e2aadbae4d70bfbc4db0196be399bc949c246391c6bbcbd674fc1aa42af/eigenpy-3.13.0-0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:0818e204858c0345e38dd18c0f0aa20082f176ce26e57cc72d9b0d1ef0df5c4e", size = 5485486, upload-time = "2026-05-21T11:01:12.154Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/3cdd0a7e0ee83ac6a914e527b0e5adf18b383ea8132ad31eab00143666ec/eigenpy-3.13.0-0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc6de29029d6ccfcf30dae12e272727982585dd67fd923d7c55fa8690948a9a1", size = 4261376, upload-time = "2026-05-21T11:01:14.157Z" }, + { url = "https://files.pythonhosted.org/packages/14/12/414f3c31d51928e9c7160e3db196f79419ca81fa01dec6283c64a67bc776/eigenpy-3.13.0-0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:caff6dbf5f063dee474ae2aba0bcdd3cb6b18566397339e4ee6386e7c5adedb1", size = 6230831, upload-time = "2026-05-21T11:01:16.372Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/439c4f16f8b6bc341137dc5ee75d896a9d032cd8dc9eedb0464eaad81f20/eigenpy-3.13.0-0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:041df9317cd2a88b2c77a7f459bba8359015540b3bb704118c150a9dea1c3d9b", size = 6068320, upload-time = "2026-05-21T11:01:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/84/fb/199ef9f6b78b8e51bfc27acc29de8610d648c37704da1b0ed261cf250581/eigenpy-3.13.0-0-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:2f99da9a17768340a0b0cff5cb3ab39458fcd7b44b8e7ab5c749f84751b9c9b4", size = 5488262, upload-time = "2026-05-21T11:01:20.253Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0d/a657817bcd640f6e806e2fddb80b99a512669225d5b578d766a59fdfda09/eigenpy-3.13.0-0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0d8459fcde1fa501ee13587ef85faa07229e1c9030f392253f4f4c7fb31c5585", size = 4271730, upload-time = "2026-05-21T11:01:22.198Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5d/4d096eb4b6ec78de2c59320454608db462de8c1b4f0bc73f59b6e8ea5543/eigenpy-3.13.0-0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:cbff5fb6902e2de7b2436864149141b47246babc2ab6ee035b5666662e495552", size = 6238730, upload-time = "2026-05-21T11:01:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/db/57/2dacd8dbdce012e8ce41329c07352803220a219cb108a3f482a680696249/eigenpy-3.13.0-0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:facffa9dde8a8d123de8e6766a3611362d2969433d8a9e83377b0c03c099044c", size = 6086683, upload-time = "2026-05-21T11:01:26.888Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "foxglove-websocket" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/b5/df32ac550eb0df9000ed78d872eb19738edecfd88f47fe08588d5066f317/foxglove_websocket-0.1.4.tar.gz", hash = "sha256:2ec8936982e478d103dd90268a572599fc0cce45a4ab95490d5bc31f7c8a8af8", size = 16616, upload-time = "2025-07-14T20:26:28.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/73/3a3e6cb864ddf98800a9236ad497d32e5b50eb1682ac659f7d669d92faec/foxglove_websocket-0.1.4-py3-none-any.whl", hash = "sha256:772e24e2c98bdfc704df53f7177c8ff5bab0abc4dac59a91463aca16debdd83a", size = 14392, upload-time = "2025-07-14T20:26:26.899Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "gpd" +version = "0.1.0" +source = { git = "https://github.com/TomCC7/gpd.git?rev=c088d8ae2f7965b067e9a12b3c0dacdbe9da924a#c088d8ae2f7965b067e9a12b3c0dacdbe9da924a" } +dependencies = [ + { name = "numpy" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "janus" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/7f/69884b6618be4baf6ebcacc716ee8680a842428a19f403db6d1c0bb990aa/janus-2.0.0.tar.gz", hash = "sha256:0970f38e0e725400496c834a368a67ee551dc3b5ad0a257e132f5b46f2e77770", size = 22910, upload-time = "2024-12-13T12:59:08.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/34/65604740edcb20e1bda6a890348ed7d282e7dd23aa00401cbe36fd0edbd9/janus-2.0.0-py3-none-any.whl", hash = "sha256:7e6449d34eab04cd016befbd7d8c0d8acaaaab67cb59e076a69149f9031745f9", size = 12161, upload-time = "2024-12-13T12:59:06.106Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "lcm-dimos-fork" +version = "1.5.2.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/53/26e5c821858fff27ed9bf5be6e848a63db89bcd6a0c4cb8546dce3396a7d/lcm_dimos_fork-1.5.2.post1.tar.gz", hash = "sha256:3ec3703605ea1ea2f82ab327d73eb7fddf3775c1365e4a112314e11f389ad72f", size = 5538346, upload-time = "2026-06-03T07:09:31.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/50/8c640088b7722d11d6c1acec133afa44e344cbb5c25236f3ca9b8bdb0d4e/lcm_dimos_fork-1.5.2.post1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e36c02538a5f4f3b7c4bf9cf7f130001deb4087e6c4ab2b87a92b63b5800eaf5", size = 3501233, upload-time = "2026-06-03T07:09:18.794Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e3/c2f4e3d67129caf105f9dade2c0a77755ea1d11e0bf13eb6041e8bf69954/lcm_dimos_fork-1.5.2.post1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ca37f8bda7733084e3a840ca71df139413fa9d7187ef39eb31425301fc8effaa", size = 2861890, upload-time = "2026-06-03T07:09:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9d/f19698bc9888c0e7d1244b02e20cb4f4fa83617f046760e7f2f21799008b/lcm_dimos_fork-1.5.2.post1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3ad82221708a7510edd7ea484733efe40c82bdb553816679b4cc0d8967d1b9be", size = 2882420, upload-time = "2026-06-03T07:09:09.346Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f8/0e300ecb1f5719a42adaadd009c27b6543576172e55f5070c8eec3e6093e/lcm_dimos_fork-1.5.2.post1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:75bd1fc3d6365bb47d1ee95c0b5dd267ca5516b393fcbc393e8c35bfb22c34b2", size = 3501253, upload-time = "2026-06-03T07:09:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bf/1673d451d032cff65214692e2431858800e4baecee32f68a9ccfe19919b8/lcm_dimos_fork-1.5.2.post1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c3c4827d7d9d618270b70c564dacbc142dbce5dc03566510ba698bfebf2830b0", size = 2861845, upload-time = "2026-06-03T07:09:14.2Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/a31d94a20c3ac5d6fd3fa89b2de66dbf6a7f01551fdff29d9a5fbf02cba6/lcm_dimos_fork-1.5.2.post1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:700294567efed85e96f2ae435b7c55d8702e15f3d06154f56216c1be5c85b322", size = 2882574, upload-time = "2026-06-03T07:09:23.407Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b1/db2c874eca7cec08d2e98b5797dd5e295e86611ae581e81d84f86e532ac4/lcm_dimos_fork-1.5.2.post1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:73f4097dab2a1f5834733b7a8b4568ab6d9b071b59df72e21141aca19c38e895", size = 3501200, upload-time = "2026-06-03T07:09:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d5/cd5292b61b8aad43769f9133fbdf7c0011b9d45d38e54de1747d60fedc1a/lcm_dimos_fork-1.5.2.post1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e1991cbd43969f1165edca5e087644ddd7544a4681229348ebed93ce680e85cd", size = 2861937, upload-time = "2026-06-03T07:09:36.409Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e7/09c44788185bd4ee887639b6cea7340116d3e8a7fecddc9b61f79f9aac7c/lcm_dimos_fork-1.5.2.post1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f6bf33c71795a2d5a4d98e28d41ae6c56d066916fa5e2c1b4e8af5a38f0fdb25", size = 2882429, upload-time = "2026-06-03T07:09:06.555Z" }, +] + +[[package]] +name = "libcoal" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-assimp" }, + { name = "cmeel-boost" }, + { name = "cmeel-octomap" }, + { name = "cmeel-qhull" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/44/d59b10af87165def48bf55fcb3f0591274a39322d48cb92d608d17ed86b5/libcoal-3.0.3.tar.gz", hash = "sha256:fd0f887682c73ef2d429caa09721993acb596b9e529c4f37761fe34faa7675ed", size = 1464501, upload-time = "2026-05-21T08:41:47.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/35/853c33314d1dcb50ff8c1d832cbe1533dd4d0cf6be685b96bbb1d5219d35/libcoal-3.0.3-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c1b3a0c4d10a690c61fb47eb42aee32bad827846bc7413238364ab340b1faf84", size = 1683905, upload-time = "2026-05-21T08:41:39.006Z" }, + { url = "https://files.pythonhosted.org/packages/14/bb/c2288420bd28f563ff6557f17d330fa07d6e5c5198b130da7efee9e53b86/libcoal-3.0.3-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c09ff80ff486f00bdcde1fbde14a143933d20c2c46f8567d6f95817339242fbf", size = 1484193, upload-time = "2026-05-21T08:41:40.917Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/d8ef12e6a3a21d40d146ccea38fc29e547d884ebd9f531105902b5408092/libcoal-3.0.3-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:18e203ed53da8b96b192a74c4c041fe33f98b0249bb7ec27a3b67397ce1ecc4d", size = 2257010, upload-time = "2026-05-21T08:41:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a1/062d7d444eb9260244cdced41384933f8cdf5b383ab9ed2810d048720bd3/libcoal-3.0.3-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:28fa1473d80728994f7275b8c8797858959ab77a643f07f2222feb6de155bb8e", size = 2285044, upload-time = "2026-05-21T08:41:45.03Z" }, +] + +[[package]] +name = "libpinocchio" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, + { name = "cmeel-urdfdom" }, + { name = "libcoal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/c9/6c1d428cc8dc10eda45726ac4898bd9ce659366c934e2c37e8e5ba3ae330/libpinocchio-4.0.0.tar.gz", hash = "sha256:425a4ea81fa046238ddb7d8e0c6109bff69a80dd945f84dc3813243b59e3d548", size = 4418773, upload-time = "2026-05-21T13:30:05.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/f4/53010f1cf23def730d774b9a99d33ffe76da1bdb63ba2e877432d7f652bf/libpinocchio-4.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c497750021075ca95715d478314970a82d7b9d0ce0e9138800378db8cbd4ac3d", size = 3677932, upload-time = "2026-05-21T13:29:58.474Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/150954766542d00e9c3ede6ea918f9ec852e7657637e5dc0c842ff059339/libpinocchio-4.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6d617a36b0a0c60774b321f1662d86aeb7161cf88581ae5c2ca4665d765ea16", size = 3116217, upload-time = "2026-05-21T13:30:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/45/65/150b7d6b3986a63d20f95c0f066fadb586fed21ab027bf4cbe8a70230474/libpinocchio-4.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:43d0ee81449126c6f1b184111cac74d83e8903a57401b8d37bde53ed946cf24e", size = 3639624, upload-time = "2026-05-21T13:30:02.244Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b4/9292b05386d51975ffe69ebcc9dee351ee8018eb0dd1acbcdf48352b2b63/libpinocchio-4.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a5b7b8d968f1fe8a57e01fb05464521c09a8d368d2b4e482bee25db018bbd197", size = 3804644, upload-time = "2026-05-21T13:30:04.052Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] +plugins = [ + { name = "mdit-py-plugins" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a2/78f662f1b18968531f67d3fcde1b7ea8496920bacd4f16ddb5b79d112e46/matplotlib-3.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f857524b442f0f36e641868ce2171aafa88cb0bc0644f4e1d8a5df9b32649fef", size = 9436261, upload-time = "2026-06-12T02:27:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/5e/92/044f1de43901310202f4c79acf4f141be53b2ca8d8380e2fcefb3d523a75/matplotlib-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57baa92fdc82948ed716eae6d2579d4d6f40965cd8d2f416755b4a72580a3233", size = 9264669, upload-time = "2026-06-12T02:27:37.413Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/f0b4f9ba7ec14a7af8151f3ad71ecfe3561e6ba38cfab1db3681ba4ca112/matplotlib-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630eee0e67d35cce2019a0e670719f4816e3b86aff0fa72729f6c69786fceb45", size = 10021076, upload-time = "2026-06-12T02:27:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/4d679c6dcd594a156542080ac907ddccf7b09ca11655c4b28eca8e9ee5da/matplotlib-3.11.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5106c444d0bf966eee2853548c03772af4ab7199118e086c62fbac8ccb07c055", size = 10828999, upload-time = "2026-06-12T02:27:42.433Z" }, + { url = "https://files.pythonhosted.org/packages/07/74/0a3683802037d8cd013144d77c247219b47f2aabace6fdde74faa12bacf7/matplotlib-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d7aea652b58e686444079be3376ef546bffa1eee9b9bb9c472b9fcf6cf410d3", size = 10913103, upload-time = "2026-06-12T02:27:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/970fcbf381e82ec66fdf5da8ea76e2e9240f61a24011ce9fd1d42c37ac2d/matplotlib-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:70a5b3e9a5dab708c0f039709ae7c68d5b4d254e291ef76492cdba230c8bb5e4", size = 9310945, upload-time = "2026-06-12T02:27:46.867Z" }, + { url = "https://files.pythonhosted.org/packages/14/4e/6e7cfed23611265ded53806852343b5c59339e506e84c474a9b5afc3b249/matplotlib-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d68266213e73823ac3be90615bab0cf31f88851e114cdb1dd25dacf3b01e1a7", size = 8999304, upload-time = "2026-06-12T02:27:48.798Z" }, + { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532, upload-time = "2026-06-12T02:28:06.769Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760, upload-time = "2026-06-12T02:28:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623, upload-time = "2026-06-12T02:28:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372, upload-time = "2026-06-12T02:28:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099, upload-time = "2026-06-12T02:28:16.383Z" }, + { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727, upload-time = "2026-06-12T02:28:18.581Z" }, + { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506, upload-time = "2026-06-12T02:28:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838, upload-time = "2026-06-12T02:28:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298, upload-time = "2026-06-12T02:28:24.796Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491, upload-time = "2026-06-12T02:28:27.141Z" }, + { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059, upload-time = "2026-06-12T02:28:29.222Z" }, + { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576, upload-time = "2026-06-12T02:28:31.66Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519, upload-time = "2026-06-12T02:28:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456, upload-time = "2026-06-12T02:28:35.878Z" }, + { url = "https://files.pythonhosted.org/packages/57/52/a94102ac99eb78e2fe9b826674f9ef9ee23327110ea6ab4776c1b4eb6209/matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79", size = 9452137, upload-time = "2026-06-12T02:28:37.93Z" }, + { url = "https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3", size = 9281514, upload-time = "2026-06-12T02:28:40.028Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2d/4e1240ea82ee197dfb3851e71f71c87eeeb975f1753b56a0588e4e80739a/matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9", size = 10843005, upload-time = "2026-06-12T02:28:42.39Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430", size = 11127459, upload-time = "2026-06-12T02:28:44.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/795c405aa7560443a3b01309424cde4a1113b85c90b8a63417444a749617/matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba", size = 10925160, upload-time = "2026-06-12T02:28:46.564Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2", size = 9485186, upload-time = "2026-06-12T02:28:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c0/396478ee7cf2091d182db8b4a8695f6a37f1ddb978989cf9dbb84cd5c123/matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d", size = 9160349, upload-time = "2026-06-12T02:28:51.382Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/1c3bd51bb2b34eaacdcf3c3d859dbb357f952fc8020c617dc118ad7c9e38/matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847", size = 9500921, upload-time = "2026-06-12T02:28:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/4d861d0121840cb1a3fd4a10deb211efd6fccd481ed23e553f31f4f4da4a/matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e", size = 9332190, upload-time = "2026-06-12T02:28:55.623Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/22f6bc35711a0b5639a784e74e653e77c86210bd4304449dd399a482f74e/matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0", size = 10854181, upload-time = "2026-06-12T02:28:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/9a9eaca731a2939589da520f0ebe8fd8753d0f51fca98c7d20af6dbe261a/matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb", size = 11137715, upload-time = "2026-06-12T02:29:00.555Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f9/9b030b6088354acb0296871bb624b25befc1c42509d3c6cd17420c83a5b8/matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9", size = 10939427, upload-time = "2026-06-12T02:29:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/59/94/6b273eaee4ee250863567d100865da61a5c1527fa67f527b7ed22e0dd29c/matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6", size = 9535809, upload-time = "2026-06-12T02:29:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/60/95/1d36bddf2b7e2692c1540e78a6e5bc88bc1496b137e3e35a611f91b65ac3/matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159", size = 9209226, upload-time = "2026-06-12T02:29:07.033Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c2/f5da6cd37ed6871f5c9b3c0507ddb69f14d6c36fac4541e4e0c60cb8cdfc/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62", size = 9434094, upload-time = "2026-06-12T02:29:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/f8/07/56f66906e0f87a0c6d0d0acbd34dbc9432b1931d8f26ef618bd6f92932a9/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd", size = 9262183, upload-time = "2026-06-12T02:29:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" }, +] + +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/b3/650500c2eab4534d98e9166f4298e0f3c69c742afdf24e6eabccd1f16ad8/numba-0.65.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7020d74b19cdb8cff16506542fdd510756e28c5e7f3bd0b7f574f0f42272fcd9", size = 2680563, upload-time = "2026-04-24T02:02:18.414Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/0615dbedb98f5b32a35a53290fbdc6e22306968109278d7e58df82d7a9f6/numba-0.65.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f80ed83774b5173abd6581cd8d2165d1d38e13d2e5c8155c0c0b421784745420", size = 3745018, upload-time = "2026-04-24T02:02:20.252Z" }, + { url = "https://files.pythonhosted.org/packages/49/aa/4361698f35bf63bff67dfe6c90493731177f48ede954f77b0588731537bc/numba-0.65.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ed425a43b0a5f9772f2f4e2dd0bbd12eabecae1af0b24efcfd4e053f012aac6", size = 3450962, upload-time = "2026-04-24T02:02:22.449Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9a/af61ec03b3116c161fd7a06b9e8a265729a8718458333e8ffbb06d9a3978/numba-0.65.1-cp311-cp311-win_amd64.whl", hash = "sha256:df40a5028a975b9ea66f6a2a3f7abbdbd541a863070e34ed367aff21141248e4", size = 2747417, upload-time = "2026-04-24T02:02:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, + { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "open3d" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "addict" }, + { name = "configargparse" }, + { name = "dash" }, + { name = "flask" }, + { name = "matplotlib" }, + { name = "nbformat" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pyquaternion" }, + { name = "pyyaml" }, + { name = "scikit-learn" }, + { name = "tqdm" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/37/8d1746fcb58c37a9bd868fdca9a36c25b3c277bd764b7146419d11d2a58d/open3d-0.19.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:117702467bfb1602e9ae0ee5e2c7bcf573ebcd227b36a26f9f08425b52c89929", size = 103098641, upload-time = "2025-01-08T07:26:12.371Z" }, + { url = "https://files.pythonhosted.org/packages/bc/50/339bae21d0078cc3d3735e8eaf493a353a17dcc95d76bcefaa8edcf723d3/open3d-0.19.0-cp311-cp311-manylinux_2_31_x86_64.whl", hash = "sha256:678017392f6cc64a19d83afeb5329ffe8196893de2432f4c258eaaa819421bb5", size = 447683616, upload-time = "2025-01-08T07:22:48.098Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3c/358f1cc5b034dc6a785408b7aa7643e503229d890bcbc830cda9fce778b1/open3d-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:02091c309708f09da1167d2ea475e05d19f5e81dff025145f3afd9373cbba61f", size = 69151111, upload-time = "2025-01-08T07:27:22.662Z" }, + { url = "https://files.pythonhosted.org/packages/37/c5/286c605e087e72ad83eab130451ce13b768caa4374d926dc735edc20da5a/open3d-0.19.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:9e4a8d29443ba4c83010d199d56c96bf553dd970d3351692ab271759cbe2d7ac", size = 103202754, upload-time = "2025-01-08T07:26:27.169Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/3723e5ade77c234a1650db11cbe59fe25c4f5af6c224f8ea22ff088bb36a/open3d-0.19.0-cp312-cp312-manylinux_2_31_x86_64.whl", hash = "sha256:01e4590dc2209040292ebe509542fbf2bf869ea60bcd9be7a3fe77b65bad3192", size = 447665185, upload-time = "2025-01-08T07:23:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/35a6e0a35aa72420e75dc28d54b24beaff79bcad150423e47c67d2ad8773/open3d-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:665839837e1d3a62524804c31031462c3b548a2b6ed55214e6deb91522844f97", size = 69169961, upload-time = "2025-01-08T07:27:35.392Z" }, +] + +[[package]] +name = "open3d-unofficial-arm" +version = "0.19.0.post9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "dash" }, + { name = "flask" }, + { name = "nbformat" }, + { name = "numpy" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/f9/edcfaa213800ea278804402baa65693840bc7a323b3de8a31c54ce4e42c8/open3d_unofficial_arm-0.19.0.post9.tar.gz", hash = "sha256:ee300bd557f04750db6e47ccb6c6867c6dd6cfc04169dddeb92505da9ea739ef", size = 5327, upload-time = "2026-04-16T21:21:11.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/63/657916febb68b6d65539ea57bc50c9e82622a1bbb5af527950d7b3f49644/open3d_unofficial_arm-0.19.0.post9-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:3b407104a000f0e44b27730e84ffef85e25ce6fb9bb09e5368c462449eb4e6f0", size = 48233468, upload-time = "2026-04-16T21:23:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9c/863a42c39d2f3d8dbed617e624b3bac04225a56be193b999009954ec0cac/open3d_unofficial_arm-0.19.0.post9-cp312-cp312-manylinux_2_31_aarch64.whl", hash = "sha256:e104c90aa35ee7c4e2470abb225522f39cff5ab2f3cf9297680be9367e846e56", size = 47305232, upload-time = "2026-04-16T21:24:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/4f2ee6cadddf2bd87dcb6f0f43fdf5acca6b24c550c695c20e328567e61a/open3d_unofficial_arm-0.19.0.post9-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:4f7c325cad5c589363723967b1275a2b6fc3776ebaee4476a44f4baef5754c24", size = 48221802, upload-time = "2026-04-16T21:24:24.015Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/7424cb61263be183284bbfcdd3c3ea6b806966135bc36df56e00f540f12a/open3d_unofficial_arm-0.19.0.post9-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:1c8c7132896a0c44a72aeaf9818e4c06b1e194da7a3b3dc640ddaa1fd2b5be3e", size = 48223501, upload-time = "2026-04-16T21:24:37.007Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[package]] +name = "pin" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, + { name = "cmeel-urdfdom" }, + { name = "coal" }, + { name = "libpinocchio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/40/72fa61797578c9121824645398497d2561ec29eb1d9cb88ca43973435834/pin-4.0.0.tar.gz", hash = "sha256:5bdf7dbf76437f797e8ea4f9a0e286f33e2af3fde38d67efc34832da2edced66", size = 4417957, upload-time = "2026-05-21T17:27:18.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/59/551829f6dd81dcb84ea681f107085afae99a45db954083aac1cbaff8a8fa/pin-4.0.0-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c123b3627af8fbfff255954bb115517cc5a8f3c572765ec259cf67b8eb6d2c7", size = 7397256, upload-time = "2026-05-21T17:26:44.54Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/d91b71ddaa46e0a3990514aa9488631acf8527a26a76f6328cd662dabdeb/pin-4.0.0-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:322b6c15b8992e01b7ea5f5de91e39dec54022b2c862dd9a0766ac446d7dfc47", size = 7134478, upload-time = "2026-05-21T17:26:46.59Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0e/ed1a5716caf890c4b02481552d60f12df4a587716af453d7d4f8ad9b7198/pin-4.0.0-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4a81cc911ca16911fffcedcacc9c159f1961691cb1fa65cadef6559515aea136", size = 9979416, upload-time = "2026-05-21T17:26:48.822Z" }, + { url = "https://files.pythonhosted.org/packages/0d/75/cc1bc40bace8ee7382f417f4dacceabcdcd20b716fa66a5de029f98a4715/pin-4.0.0-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:62ce8dfcc92f43c0f765d9e145a21da914f9b2adb9ee724e87a870f03995fe2d", size = 10207826, upload-time = "2026-05-21T17:26:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/91/ab/f2f7e8b5eba232adba310dafbf78c1a8722fde77a5c5defab88693bfe90e/pin-4.0.0-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8be5f63b34b7f5da40e6f1f4554ff2f0606c425f89347b0f25f5ce0d3d42a56b", size = 7469991, upload-time = "2026-05-21T17:26:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/88/ca/8cd3f29177213abf9c9714039e1e916694edc935b6644fc33bb037b358d3/pin-4.0.0-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1abe8107a14d7507983324e3866ed043653d846029148299d1965d0ce32d31e7", size = 7185247, upload-time = "2026-05-21T17:26:55.148Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/76c375d6d0e044094686c566f2e14d39a86f35e1286e546be6d05a82cf6e/pin-4.0.0-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1d9b4df62e71e1be729ceadc344aeda65dac3581caa4368f2e4377a991b66329", size = 9880446, upload-time = "2026-05-21T17:26:57.183Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/5d3400107ea829ff9002786ea483d0ac6590caaa8ba427c2d5f588a1a3cd/pin-4.0.0-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8ec1a71de6d8ed6de9e567c5c3dff22a044a3c3671590ee887d930763b267ee3", size = 10117715, upload-time = "2026-05-21T17:26:59.353Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b8/78abb97f8016eab014c95ee0450320b49b688aa4f1e5c8d05abc8bbe7cf2/pin-4.0.0-0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:335c339c9ab0347a5f97cc7dc8cb8223848eaf129170bc99ada8406fe9bfae6b", size = 7469985, upload-time = "2026-05-21T17:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/e1/66/9387b259bede7dbdbf88271cd2a8d3da16e52ada868df34066b38f26ab8f/pin-4.0.0-0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1cbf4d19cd0b2ec36586c8eca3a1dfcf505dbf6460a4c1871ee413016b504837", size = 7185240, upload-time = "2026-05-21T17:27:03.646Z" }, + { url = "https://files.pythonhosted.org/packages/67/9e/66be1862119f9d9937adb4d58acac97197c1f6907f9c33eab013a5ae42f1/pin-4.0.0-0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3007fe31ce670b970cb9a4ea020f68f1840d579d171cadb708c460e893691106", size = 9880447, upload-time = "2026-05-21T17:27:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/8a/de/0fefb66a60cb584e51c86769031977279dbdd177b08c07b481b3ae2bdf82/pin-4.0.0-0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e0d099fdd4118560d927a31559773acba94ac687259944348d9c513efd50a4b0", size = 10117711, upload-time = "2026-05-21T17:27:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f9/354d6a34bdc7a5e8a48ce9e57af129adafc2d440fd380b99189767c46e42/pin-4.0.0-0-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:acd837481bd5c5e2cb738077545f1640e67a9df7a402dac53f5d8b7ffde5ef74", size = 7488077, upload-time = "2026-05-21T17:27:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/ec/12/136f1d7a37e5a9c02469a413c0b5a60e42422360d53353fe8fec807c3620/pin-4.0.0-0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c19322c5107b3bc0a1a393b9333401c6e0202499c0d1e06f79c58db0c4daa595", size = 7207864, upload-time = "2026-05-21T17:27:11.846Z" }, + { url = "https://files.pythonhosted.org/packages/5f/38/e9c0dec21d4b83f37336eda3238c820a66469bddda865291ab80c48ce492/pin-4.0.0-0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2ecac51096eba64eccf491415af4a3ea2667e8161563cff9a2dfc768282c7847", size = 9918160, upload-time = "2026-05-21T17:27:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ca/e783bf65a76aa2d36a7cdccda7b77bba4e611efa44d870ec6032bfb9caf7/pin-4.0.0-0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f3e5f897beee5ec009f368d221fd706078bd56d3ddc8edbb07fe646451f5db5a", size = 10130188, upload-time = "2026-05-21T17:27:15.776Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + +[[package]] +name = "plotly" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/fd/d72c292d78aadb93d1a9bcd76bf3c678271040c7cf10abe5788b33040a39/plotly-6.8.0.tar.gz", hash = "sha256:e088e7ddc68d4f70e3d66659224727a45296d71d2b8284181862d3d8f1f0d88f", size = 6915161, upload-time = "2026-06-03T18:33:40.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl", hash = "sha256:13c5c4a0f70b74cab1913eda0de49b826df5931708eb6f9c3010040614700ec8", size = 9902055, upload-time = "2026-06-03T18:33:34.26Z" }, +] + +[[package]] +name = "plum-dispatch" +version = "2.5.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/46/ab3928e864b0a88a8ae6987b3da3b7ae32fe0a610264f33272139275dab5/plum_dispatch-2.5.7.tar.gz", hash = "sha256:a7908ad5563b93f387e3817eb0412ad40cfbad04bc61d869cf7a76cd58a3895d", size = 35452, upload-time = "2025-01-17T20:07:31.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/31/21609a9be48e877bc33b089a7f495c853215def5aeb9564a31c210d9d769/plum_dispatch-2.5.7-py3-none-any.whl", hash = "sha256:06471782eea0b3798c1e79dca2af2165bafcfa5eb595540b514ddd81053b1ede", size = 42612, upload-time = "2025-01-17T20:07:26.461Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/91/c76f3c5e8e80c7047e43c4c05b3e6fda9a7cefad5aae85487007674c966c/pyobjc_framework_corebluetooth-12.2.1.tar.gz", hash = "sha256:7dbb285295097205bebbcb11f55161e5faa02111108fb7b17536176e31971eb0", size = 37568, upload-time = "2026-06-19T16:20:12.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/ad/de57d9060e4c5e9ca1a9bdd5b6bd1bdb73b198c0c53953cac445d9b3a84b/pyobjc_framework_corebluetooth-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f11df7052fa2d0524a9dadbc9c578fd3b8f3a350431edfa4ffa9e0a74b6faa32", size = 13195, upload-time = "2026-06-19T16:08:26.961Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c4/7938016860850e28c001dc9b7c653352c43f7aebfffc6d3c5fd087281f22/pyobjc_framework_corebluetooth-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2f8d2ed65c0e98ba044b6a7fc2f9a290a5c579a28d33a57c642f8617ccbcca0f", size = 13217, upload-time = "2026-06-19T16:08:27.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/79/890a53ed45c1006eedcf60627b7d661c8696e5367723ceb25cc6a0216b30/pyobjc_framework_corebluetooth-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:30a26eef36c250fc14e73335641e24f764b32b7e42bae945a5d8a1c2347040b5", size = 13235, upload-time = "2026-06-19T16:08:28.727Z" }, + { url = "https://files.pythonhosted.org/packages/22/7a/40ffc3be8e31b1eb1f8f5eb2a58ef832287fb1ea6b3c452dc8b25b9e064b/pyobjc_framework_corebluetooth-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:756933b9ce6160a986c8877ab667659fa2d8e15aff28d0981b5284fbdd1ea735", size = 13416, upload-time = "2026-06-19T16:08:29.643Z" }, + { url = "https://files.pythonhosted.org/packages/30/ff/6f3b0bb3110ec82dbedaea47de151bd688980f5aadc634ef0cd236fdbd16/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2a2e6d56f51e4ca3e3b9766ef34150a9a7ce5f0cf4f9ee879ec10923af58e97e", size = 13223, upload-time = "2026-06-19T16:08:30.672Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/4e12660569219e4a68186ae9709b85278d3ebaf8d2f8e1c826a7337f4f7a/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50d7e4245dbdc8789dcc1f11fca2e633aa126a298b09db62f8216531fe107ee2", size = 13414, upload-time = "2026-06-19T16:08:31.679Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4c/976ae9bcce3615af806e3c314ea9caa3faacf11ec44f00b1a149559c6cb3/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c8d126c56b71c25218be186930a1b41739f83e931726a52e3298beeb170c5e5b", size = 13222, upload-time = "2026-06-19T16:08:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/99/be/44bb648a6b5c8aec79138bf562dab9eef414016ee31f37066bf81d809ae9/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:81023518feb75e9b2b676b28198955c51ae00548cf23c73c524c7101263b68db", size = 13424, upload-time = "2026-06-19T16:08:33.336Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/3f/561653aff3f19873457c95c053f0298da517be89fdfc0ec35115ed5b7030/pyobjc_framework_libdispatch-12.2.1.tar.gz", hash = "sha256:0d24eda41c6c258135077f60d410e704bc7b5a67adcb2ca463918896c7363795", size = 40336, upload-time = "2026-06-19T16:20:56.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/b0/dc263ed1cee54badccaf60f061de1e25bea95504984401a22bee274b5f59/pyobjc_framework_libdispatch-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e295775c76eace23f60e53ef74b0f79429c665f91a870e4665c4b9887466efa", size = 20488, upload-time = "2026-06-19T16:12:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8f/42cfa987c07a2b5ce8c236a42b0fb388b8807dac72c25e004cd4905ea9a3/pyobjc_framework_libdispatch-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8f41b5021ff70bc51220a79b41ebd1eacb55fe3ceb67448594f30e491a2c42a5", size = 15656, upload-time = "2026-06-19T16:12:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/cfe97f1beb13f5b7ca5c4348158c2de886d58ffba5be09a9376557f7d6f6/pyobjc_framework_libdispatch-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9c0ebf99520083bf17c007a544c100056a0d4ae5c346fb89e1bdfe6d041f16f2", size = 15679, upload-time = "2026-06-19T16:12:51.28Z" }, + { url = "https://files.pythonhosted.org/packages/d7/de/ef6b51bc72fe5ac1df80c34b1b13a97d0922ddd6bc5d3ecf5ead1557bf34/pyobjc_framework_libdispatch-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3f56fd71b963a0b6e440ed2f0ea2fb635221758b7eb908ba38f96f5144b83ca3", size = 15946, upload-time = "2026-06-19T16:12:52.098Z" }, + { url = "https://files.pythonhosted.org/packages/42/87/5b4a6c8580f2a486daf4b0d14a2356c47abfda401b329e71e46ac9b5460c/pyobjc_framework_libdispatch-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:999bad9a2c9198c837ba8f57a3ca9f05b4fc4bf7b69318baaa266dd2ab2fc8f7", size = 15699, upload-time = "2026-06-19T16:12:52.917Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/68cff50cb37a6ea311b7e805105ea13c33043762772714bc25d269c0730d/pyobjc_framework_libdispatch-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3fc93971f40d9757995c1e4b995a1614a468a5178be27e3d81e9bdc0b5e3cf75", size = 15981, upload-time = "2026-06-19T16:12:53.845Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5d/1f48e023555817f1271e86849ebd092743fc8bd292b6f82e87aba5df6122/pyobjc_framework_libdispatch-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:26a096c81c8cf272f4f1bb8f6c4b7565e005d273d218b53b83d925da5292633f", size = 15719, upload-time = "2026-06-19T16:12:54.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/44/b45c32851a3bcd367c62804c23aa55ea7918af6e16fddf1df23f5d7ca750/pyobjc_framework_libdispatch-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:82c6512fb4985f3bcd6b60b0cff79a4b483b44d1d2e5405010e34dd4b60aa01b", size = 16009, upload-time = "2026-06-19T16:12:55.513Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyquaternion" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/3d092aa20efaedacb89c3221a92c6491be5b28f618a2c36b52b53e7446c2/pyquaternion-0.9.9.tar.gz", hash = "sha256:b1f61af219cb2fe966b5fb79a192124f2e63a3f7a777ac3cadf2957b1a81bea8", size = 15530, upload-time = "2020-10-05T01:31:30.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b3/d8482e8cacc8ea15a356efea13d22ce1c5914a9ee36622ba250523240bf2/pyquaternion-0.9.9-py3-none-any.whl", hash = "sha256:e65f6e3f7b1fdf1a9e23f82434334a1ae84f14223eee835190cd2e841f8172ec", size = 14361, upload-time = "2020-10-05T01:31:37.575Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyturbojpeg" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/e8/0cbd6e4f086a3b9261b2539ab5ddb1e3ba0c94d45b47832594d4b4607586/PyTurboJPEG-1.8.2.tar.gz", hash = "sha256:b7d9625bbb2121b923228fc70d0c2b010b386687501f5b50acec4501222e152b", size = 12694, upload-time = "2025-06-22T07:26:45.861Z" } + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "reactivex" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/af/38a4b62468e4c5bd50acf511d86fe62e65a466aa6abb55b1d59a4a9e57f3/reactivex-4.1.0.tar.gz", hash = "sha256:c7499e3c802bccaa20839b3e17355a7d939573fded3f38ba3d4796278a169a3d", size = 113482, upload-time = "2025-11-05T21:44:24.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/9e/3c2f5d3abb6c5d82f7696e1e3c69b7279049e928596ce82ed25ca97a08f3/reactivex-4.1.0-py3-none-any.whl", hash = "sha256:485750ec8d9b34bcc8ff4318971d234dc4f595058a1b4435a74aefef4b2bc9bd", size = 218588, upload-time = "2025-11-05T21:44:23.015Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rerun-sdk" +version = "0.32.0a1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pyarrow" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e0/5d92c02d6ca63b5692e72ad3c5b89e32d44c3a6a99a74be3cb9a9d2c9741/rerun_sdk-0.32.0a1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:704e031c4ed3eb83837ce37b73741082ab669a2b4c88348a08ba61e3ba6d8656", size = 123843351, upload-time = "2026-04-29T18:59:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/1daa4751a281bdb6cda3473229b2746779093ffaba70dd868a3074ef3392/rerun_sdk-0.32.0a1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0a821b30f287dabbf694aa1715e4dad0e31990cf4b612757c895f096885512e9", size = 133447478, upload-time = "2026-04-29T18:59:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/84170e6722a09294b57d578fc1d01266c6e78408a544876f5271ecfa66a9/rerun_sdk-0.32.0a1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8045beb820372e092a6cf100fb87091fa804168e5cf8a0c29f97cdee56b134d8", size = 137724590, upload-time = "2026-04-29T18:59:55.238Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2f/09845dcdf14047cc8118566572628c533f57f6a232ee0b3fd61283b153e8/rerun_sdk-0.32.0a1-cp310-abi3-win_amd64.whl", hash = "sha256:1d6f8c3d50699ae075551a62e971730870a685cb74189b550a348561f37f9fe5", size = 118766780, upload-time = "2026-04-29T19:00:00.121Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "terminaltexteffects" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/92/0eb3f0ad206bf449b7db75f061202dce27d8cb90e598ce3c7d32c0bd80b9/terminaltexteffects-0.12.2.tar.gz", hash = "sha256:4a5eef341d538743e7ac4341cd74d47afc9d0345acdad330ed03fd0a72e41f5f", size = 164321, upload-time = "2025-10-20T20:58:26.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/93/a588ab8b15ceeef23042aa52660fb4891a0e955e92cd3aa97dcafe621720/terminaltexteffects-0.12.2-py3-none-any.whl", hash = "sha256:4b986034094007aa9a31cb1bd16d5d8fcac9755fb6a5da8f74ee7b70c0fa2d63", size = 189344, upload-time = "2025-10-20T20:58:24.425Z" }, +] + +[[package]] +name = "textual" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify", "plugins"] }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/83/c99c252c3fad2f7010ceb476a31af042eec71da441ffeef75bb590bc2e9e/textual-3.7.1.tar.gz", hash = "sha256:a76ba0c8a6c194ef24fd5c3681ebfddca55e7127c064a014128c84fbd7f5d271", size = 1604038, upload-time = "2025-07-09T09:04:45.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f1/8929fcce6dc983f7a260d0f3ddd2a69b74ba17383dbe57a7e0a9e085e8be/textual-3.7.1-py3-none-any.whl", hash = "sha256:ab5d153f4f65e77017977fa150d0376409e0acf5f1d2e25e2e4ab9de6c0d61ff", size = 691472, upload-time = "2025-07-09T09:04:43.626Z" }, +] + +[[package]] +name = "textual-serve" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-jinja2" }, + { name = "jinja2" }, + { name = "rich" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/7e/62fecc552853ec6a178cb1faa2d6f73b34d5512924770e7b08b58ff14148/textual_serve-1.1.3.tar.gz", hash = "sha256:f8f636ae2f5fd651b79d965473c3e9383d3521cdf896f9bc289709185da3f683", size = 448340, upload-time = "2025-11-01T16:22:36.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fe/108e7773349d500cf363328c3d0b7123e03feda51e310a3a5b136ac8ca71/textual_serve-1.1.3-py3-none-any.whl", hash = "sha256:207a472bc6604e725b1adab4ab8bf12f4c4dc25b04eea31e4d04731d8bf30f18", size = 447339, upload-time = "2025-11-01T16:22:35.209Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/8d/d7ae0e07cd85c7768de76e8578261854f2af72bd3a8a527bb675e8ae0eda/winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022", size = 210798, upload-time = "2025-06-06T06:44:04.775Z" }, + { url = "https://files.pythonhosted.org/packages/ac/66/d05f6e6c0517654734e7f87fa1f0fbc965add9f27cc36b524d96331ab3d8/winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c", size = 242032, upload-time = "2025-06-06T06:44:06.103Z" }, + { url = "https://files.pythonhosted.org/packages/39/a5/760c8396110f6d3e4c417752da1a2bf3b89e0998329c2f10afc717ef6291/winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130", size = 415659, upload-time = "2025-06-06T06:44:07.007Z" }, + { url = "https://files.pythonhosted.org/packages/d3/54/3dd06f2341fab6abb06588a16b30e0b213b0125be7b79dafc3bdba3b334a/winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1", size = 210090, upload-time = "2025-06-06T06:44:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a1/1d7248d5c62ccbea5f3e0da64ca4529ce99c639c3be2485b6ed709f5c740/winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576", size = 241391, upload-time = "2025-06-06T06:44:09.442Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ae/6a205d8dafc79f7c242be7f940b1e0c1971fd64ab3079bda4b514aa3d714/winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195", size = 415242, upload-time = "2025-06-06T06:44:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/1a555d8bdcb8b920f8e896232c82901cc0cda6d3e4f92842199ae7dff70a/winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1", size = 210022, upload-time = "2025-06-06T06:44:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/aa/24/2b6e536ca7745d788dfd17a2ec376fa03a8c7116dc638bb39b035635484f/winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d", size = 241349, upload-time = "2025-06-06T06:44:12.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7f/6d72973279e2929b2a71ed94198ad4a5d63ee2936e91a11860bf7b431410/winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159", size = 415126, upload-time = "2025-06-06T06:44:13.702Z" }, + { url = "https://files.pythonhosted.org/packages/c8/87/88bd98419a9da77a68e030593fee41702925a7ad8a8aec366945258cbb31/winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad", size = 210257, upload-time = "2025-09-20T07:06:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/e5c2a10d287edd9d3ee8dc24bf7d7f335636b92bf47119768b7dd2fd1669/winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814", size = 241873, upload-time = "2025-09-20T07:06:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/eb9e78397132175f70dd51dfa4f93e489c17d6b313ae9dce60369b8d84a7/winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650", size = 416222, upload-time = "2025-09-20T07:06:43.376Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/cf/671bf29337323cc08f9969cb32312f217d2927d29dbf2964f0dbb378cb90/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b", size = 105535, upload-time = "2025-06-06T07:00:08.146Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d5/5761a8b6dcc56957018970dd443059c8ee8a79de7b07f0b4d143f8e7dc15/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d", size = 114612, upload-time = "2025-06-06T07:00:08.984Z" }, + { url = "https://files.pythonhosted.org/packages/24/0b/7819bb102286752d3572a75d03e6a8000ffe3c6cb7aee3eb136dca383fe2/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80", size = 105017, upload-time = "2025-06-06T07:00:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/54/ff/c4a3de909a875b46fad5e9f4fd412bba48571405bfa802b878954abf128c/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c", size = 105752, upload-time = "2025-06-06T07:00:10.684Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/bfee1f0c8d188c561c5b946ab21f6a0037e60dea110e80b1d6a1d529639f/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca", size = 113356, upload-time = "2025-06-06T07:00:11.541Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/d9da9c29d36cabadef4e19c3e9ba6d2692f6a28224c81fcff757132ea0da/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148", size = 104724, upload-time = "2025-06-06T07:00:12.406Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/797516c5c0f8d7f5b680862e0ed7c1087c58aec0bcf57a417fa90f7eb983/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4", size = 105757, upload-time = "2025-06-06T07:00:13.269Z" }, + { url = "https://files.pythonhosted.org/packages/05/6d/f60588846a065e69a2ec5e67c5f85eb45cb7edef2ee8974cd52fa8504de6/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3", size = 113363, upload-time = "2025-06-06T07:00:14.135Z" }, + { url = "https://files.pythonhosted.org/packages/2c/13/2d3c4762018b26a9f66879676ea15d7551cdbf339c8e8e0c56ea05ea31ef/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2", size = 104722, upload-time = "2025-06-06T07:00:14.999Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/91cfdf941a1ba791708ab3477fc4e46793c8fe9117fc3e0a8c5ac5d7a09c/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a", size = 109015, upload-time = "2025-09-20T07:09:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/7460655628d0f340a93524f5236bb9f8514eb0e1d334b38cba8a89f6c1a6/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c", size = 115931, upload-time = "2025-09-20T07:09:51.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/70/e1248dea2ab881eb76b61ff1ad6cb9c07ac005faf99349e4af0b29bc3f1b/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102", size = 109561, upload-time = "2025-09-20T07:09:52.733Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/5e/c628719e877a89f00cac7ce53f9666acbc5ed6f074130729d5d6768b63ff/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9", size = 89614, upload-time = "2025-06-06T07:00:20.952Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1a/d172d6f1c2fae53535e7f23835025cf39e3002749a0304f18a38e8ed490d/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09", size = 95783, upload-time = "2025-06-06T07:00:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/67/c1/568dfdaea62ca3b13bb70162cb292e5cd0be5bbb98b738961ddcc2edd374/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401", size = 89253, upload-time = "2025-06-06T07:00:22.603Z" }, + { url = "https://files.pythonhosted.org/packages/c9/15/ad05c28e049208c97011728e2debdb45439175f75efe357b6faa4c9ba099/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4", size = 90033, upload-time = "2025-06-06T07:00:23.421Z" }, + { url = "https://files.pythonhosted.org/packages/26/48/074779081841f6eba4987930c4e7adcec38a5985b7dffd9fecc41f39a89c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89", size = 95824, upload-time = "2025-06-06T07:00:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/aa/25/e01966033a02b2d0718710bb47ef4f6b9b5a619ca2c857e06eb5c8e3ed13/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff", size = 89311, upload-time = "2025-06-06T07:00:25.029Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/8fc8e57605ea08dd0723c035ed0c2d0435dace2bc80a66d33aecfea49a56/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90", size = 90037, upload-time = "2025-06-06T07:00:25.818Z" }, + { url = "https://files.pythonhosted.org/packages/86/83/503cf815d84c5ba8c8bc61480f32e55579ebf76630163405f7df39aa297b/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943", size = 95822, upload-time = "2025-06-06T07:00:26.666Z" }, + { url = "https://files.pythonhosted.org/packages/32/13/052be8b6642e6f509b30c194312b37bfee8b6b60ac3bd5ca2968c3ea5b80/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d", size = 89326, upload-time = "2025-06-06T07:00:27.477Z" }, + { url = "https://files.pythonhosted.org/packages/27/3d/421d04a20037370baf13de929bc1dc5438b306a76fe17275ec5d893aae6c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214", size = 92264, upload-time = "2025-09-20T07:09:53.563Z" }, + { url = "https://files.pythonhosted.org/packages/07/c7/43601ab82fe42bcff430b8466d84d92b31be06cc45c7fd64e9aac40f7851/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2", size = 97517, upload-time = "2025-09-20T07:09:54.411Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/e3303f6a25a2d98e424b06580fc85bbfd068f383424c67fa47cb1b357a46/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a", size = 94122, upload-time = "2025-09-20T07:09:55.187Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/349a5d958be8c0570f0a49bbb746088bcfaa81555accb57503ba01185359/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c", size = 182312, upload-time = "2025-06-06T07:00:49.974Z" }, + { url = "https://files.pythonhosted.org/packages/90/db/929ab0085ec89e46bd3a58c74b451dd770c3285dfa0cbd4f4aa4730da004/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415", size = 187768, upload-time = "2025-06-06T07:00:50.853Z" }, + { url = "https://files.pythonhosted.org/packages/a3/53/f316e2224c384178204430439f04f9b72017fe8237e341a9aebb20da8191/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7", size = 184189, upload-time = "2025-06-06T07:00:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a1/75ac783a5faee9b455fef2f53b7fef97b21ed60d52401b44c690202141e4/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557", size = 183326, upload-time = "2025-06-06T07:00:52.662Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d9/a9dcc15322d2f5c7dfd491bd7ab121e36437caf78ebfa92bc0dd0546e2ca/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d", size = 187810, upload-time = "2025-06-06T07:00:53.594Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fc/47d00af076f558267097af3050910beda6bf8a21ceaa5830bbd26fcaf85e/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984", size = 184516, upload-time = "2025-06-06T07:00:55.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/93/30b45ce473d1a604908221a1fa035fe8d5e4bb9008e820ae671a21dab94c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0", size = 183342, upload-time = "2025-06-06T07:00:56.16Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3b/eb9d99b82a36002d7885206d00ea34f4a23db69c16c94816434ded728fa3/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30", size = 187844, upload-time = "2025-06-06T07:00:57.134Z" }, + { url = "https://files.pythonhosted.org/packages/84/9b/ebbbe9be9a3e640dcfc5f166eb48f2f9d8ce42553f83aa9f4c5dcd9eb5f5/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383", size = 184540, upload-time = "2025-06-06T07:00:58.081Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/cb447ca7730a1e05730272309b074da6a04af29a8c0f5121014db8a2fc02/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f", size = 185249, upload-time = "2025-09-20T07:09:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fa/f465d5d44dda166bf7ec64b7a950f57eca61f165bfe18345e9a5ea542def/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851", size = 193739, upload-time = "2025-09-20T07:09:59.893Z" }, + { url = "https://files.pythonhosted.org/packages/78/08/51c53ac3c704cd92da5ed7e7b9b57159052f6e46744e4f7e447ed708aa22/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892", size = 194836, upload-time = "2025-09-20T07:10:00.87Z" }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/92/ca1fd311d96fce15fba25543a2ae3cb829744a8af548a11d74233d0e4f64/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011", size = 129898, upload-time = "2025-06-06T07:01:59.687Z" }, + { url = "https://files.pythonhosted.org/packages/03/fd/5bd5da5d7997725ba3f1995c16aa1c3362937f8ff68ad4cadfd3415eebcb/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd", size = 142361, upload-time = "2025-06-06T07:02:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/df/be/d423b63e740600e0617ddb85fba3ef99e7bbff02299fe46323bfe624a382/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817", size = 135808, upload-time = "2025-06-06T07:02:01.4Z" }, + { url = "https://files.pythonhosted.org/packages/31/3e/81642208ecd6c6c936f35a39a433c54e3f68e09d316546b8f953581ae334/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3", size = 130249, upload-time = "2025-06-06T07:02:02.237Z" }, + { url = "https://files.pythonhosted.org/packages/00/f4/a9ede5f3f0d86abfc7590726cf711133d97419b49ced372fca532e4f0696/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc", size = 141512, upload-time = "2025-06-06T07:02:03.424Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/4fad07c03124bdc3acd64f80f3bd3cc4417ea641e07bb16a9503afd3e554/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca", size = 135383, upload-time = "2025-06-06T07:02:04.312Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7d/ebd712ab8ccd599c593796fbcd606abe22b5a8e20db134aa87987d67ac0e/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9", size = 130276, upload-time = "2025-06-06T07:02:05.178Z" }, + { url = "https://files.pythonhosted.org/packages/70/de/f30daaaa0e6f4edb6bd7ddb3e058bd453c9ad90c032a4545c4d4639338aa/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015", size = 141536, upload-time = "2025-06-06T07:02:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/9a6aafdc74a085c550641a325be463bf4b811f6f605766c9cd4f4b5c19d2/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4", size = 135362, upload-time = "2025-06-06T07:02:06.997Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/5785cd1ec54dc0f0e6f3e6a466d07a62b8014a6e2b782e80444ef87e83ab/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106", size = 134252, upload-time = "2025-09-20T07:10:12.935Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/68d91068048410f49794c0b19c45759c63ca559607068cfe5affba2f211b/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472", size = 145509, upload-time = "2025-09-20T07:10:13.797Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a4/898951d5bfc474aa9c7d133fe30870f0f2184f4ba3027eafb779d30eb7bc/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22", size = 141334, upload-time = "2025-09-20T07:10:14.637Z" }, +] + +[[package]] +name = "winrt-windows-devices-radios" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/a0/4a8b51da15de218cec04bcc1cd85b4b93bcfd8ebe50a5f0a7eee28836dc6/winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e", size = 38505, upload-time = "2025-06-06T07:07:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/de/49/ba69e3180585dbc6f3336a09fef7cba4558a6a1e7d500500f62c1478418e/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104", size = 40157, upload-time = "2025-06-06T07:07:59.948Z" }, + { url = "https://files.pythonhosted.org/packages/9c/92/64817f71a20ecf842da36dc3848f42614217688137a69c93fda8a6103155/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a", size = 36976, upload-time = "2025-06-06T07:08:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e0/4731a3c412318b2c5e74a8803a32e2fb9afc2c98368c6b61a422eb359e7e/winrt_windows_devices_radios-3.2.1-cp312-cp312-win32.whl", hash = "sha256:c3e683ce682338a5a5ed465f735e223ba7a22f16d0bbea2d070962bc7657edbb", size = 38606, upload-time = "2025-06-06T07:08:01.477Z" }, + { url = "https://files.pythonhosted.org/packages/37/8e/91464854dfc9e0be9ce8dcbe2bd6a67c19b68ab91584fc5de0f4f13e78f8/winrt_windows_devices_radios-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a116e552a3f38607b9be558fb2e7de9b4450d1f9080069944d74d80cdda1873e", size = 40172, upload-time = "2025-06-06T07:08:02.214Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0d/1bd62f606b6c4dfa936fccc4712be5506a40fc5d1b7177c3d3cbcaf30972/winrt_windows_devices_radios-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4c28822f9251c9d547324f596b5c2581f050254ded05e5b786c650a3502744c1", size = 36989, upload-time = "2025-06-06T07:08:03.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/94/c22a14fd424632f3f3c0b25672218db9e8f4ae9e1355e0b148f2fe6015b5/winrt_windows_devices_radios-3.2.1-cp313-cp313-win32.whl", hash = "sha256:ae4a0065927fcd2d10215223f8a46be6fb89bad71cb4edd25dae3d01c137b3a8", size = 38613, upload-time = "2025-06-06T07:08:04.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/c1/24cec0cc228642554b48d436a7617d7162fb952919c55fc26e2d99c310bd/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:bf1a975f46a2aa271ffea1340be0c7e64985050d07433e701343dddc22a72290", size = 40180, upload-time = "2025-06-06T07:08:04.849Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/776453af26e78c0d0c0e1bfa89f86fd81322872f31a3e5dafb344dd47bf2/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:10b298ed154c5824cea2de174afce1694ed2aabfb58826de814074027ffef96f", size = 36989, upload-time = "2025-06-06T07:08:05.576Z" }, + { url = "https://files.pythonhosted.org/packages/76/79/4627afae6b389ddd1e5f1d691663c6b14d6c8f98959082aed1217cc57ef9/winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6", size = 39521, upload-time = "2025-09-20T07:11:17.992Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/c6aea91908ee7279ed51d12157bc8aeecb8850af2441073c3c91b261ad31/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf", size = 41121, upload-time = "2025-09-20T07:11:18.747Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/652f14e3c501452ad8e0723518d9bbd729219b47f4a4dbe2966c2f82dca8/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312", size = 38114, upload-time = "2025-09-20T07:11:19.599Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/36/09b9757f7cbf269e67008ea2ad188a44f974c94c9b49ebf0b52d1a8c4069/winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437", size = 111944, upload-time = "2025-06-06T07:10:58.496Z" }, + { url = "https://files.pythonhosted.org/packages/05/a5/216d66df6bdcee58eb3877fabc1544337e23f850bf9f93838db7f5698371/winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e", size = 118465, upload-time = "2025-06-06T07:10:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/be/ca/48ca8b5bc5be5c7a5516c9e1d9a21861b4217e1b4ee57923aab6f13fa411/winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671", size = 109609, upload-time = "2025-06-06T07:11:00.54Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/495e304ddedd5ff2f196efbde906265cb75ade4d79e2937837f72ef654a0/winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b", size = 112169, upload-time = "2025-06-06T07:11:01.438Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5e/b5059e4ece095351c496c9499783130c302d25e353c18031d5231b1b3b3c/winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9", size = 118668, upload-time = "2025-06-06T07:11:02.475Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/acbcb3ef07b1b67e2de4afab9176a5282cfd775afd073efe6828dfc65ace/winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37", size = 109671, upload-time = "2025-06-06T07:11:03.538Z" }, + { url = "https://files.pythonhosted.org/packages/7b/71/5e87131e4aecc8546c76b9e190bfe4e1292d028bda3f9dd03b005d19c76c/winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46", size = 112184, upload-time = "2025-06-06T07:11:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/8d5108461351d4f6017f550af8874e90c14007f9122fa2eab9f9e0e9b4e1/winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479", size = 118672, upload-time = "2025-06-06T07:11:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/44/f5/2edf70922a3d03500dab17121b90d368979bd30016f6dbca0d043f0c71f1/winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4", size = 109673, upload-time = "2025-06-06T07:11:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/d77346e39fe0c81f718cde49f83fe77c368c0e14c6418f72dfa1e7ef22d0/winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da", size = 114590, upload-time = "2025-09-20T07:11:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/4d2b545bea0f34f68df6d4d4ca22950ff8a935497811dccdc0ca58737a05/winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5", size = 122148, upload-time = "2025-09-20T07:11:50.826Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/b9d3a11cac73444c0a3703200161cd7267dab5ab85fd00e1f965526e74a8/winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87", size = 114360, upload-time = "2025-09-20T07:11:51.626Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/b3/7e4a75c62e86bedf9458b7ec8dfed74cff3236e0b4b2288f95967d5cc4d2/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7", size = 59693, upload-time = "2025-06-06T07:11:13.388Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/049db1d95fdfc0c8451dc6db17442ed4e6b2aba361c425c0bb8dc8c98c4a/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89", size = 70828, upload-time = "2025-06-06T07:11:14.54Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6b/a04974f5555c86452e54c19d063d9fd45f0fe9f2a6858e7fe12c639043fb/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523", size = 59051, upload-time = "2025-06-06T07:11:15.379Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0b/7802349391466d3f7e8f62f588f36a1a0b6560abfcdbdaa426fe21d322b4/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6", size = 60060, upload-time = "2025-06-06T07:11:16.173Z" }, + { url = "https://files.pythonhosted.org/packages/37/94/5b888713e472746635a382e523513ab1b8200af55c5b56bc70e1e4369115/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b", size = 69058, upload-time = "2025-06-06T07:11:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/829273622c9b37c67b97f187b92be318404f7d33db045e31d72b7d50f54c/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84", size = 58793, upload-time = "2025-06-06T07:11:17.837Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cd/99ef050d80bea2922fa1ded93e5c250732634095d8bd3595dd808083e5ca/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9", size = 60063, upload-time = "2025-06-06T07:11:18.65Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/4f75fd6a4c96f1e9bee198c5dc9a9b57e87a9c38117e1b5e423401886353/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10", size = 69057, upload-time = "2025-06-06T07:11:19.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/de47ccc390017ec5575e7e7fd9f659ee3747c52049cdb2969b1b538ce947/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2", size = 58792, upload-time = "2025-06-06T07:11:20.24Z" }, + { url = "https://files.pythonhosted.org/packages/e1/47/b3301d964422d4611c181348149a7c5956a2a76e6339de451a000d4ae8e7/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab", size = 62211, upload-time = "2025-09-20T07:11:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/5f2c940ff606297129e93ebd6030c813e6a43a786de7fc33ccb268e0b06b/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4", size = 70399, upload-time = "2025-09-20T07:11:53.254Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/2c8eb89062c71d4be73d618457ed68e7e2ba29a660ac26349d44fc121cbf/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01", size = 61392, upload-time = "2025-09-20T07:11:53.993Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/60/a9e0dc03434aa29e6b5c83067e988cd5934adf830cd9f87cbbc06569ca32/winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69", size = 127509, upload-time = "2025-06-06T14:01:58.971Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/6c9c21b5e75ff5927a130da9eaf5ab628dfa1f93b64c181f0193706cbd6c/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5", size = 132491, upload-time = "2025-06-06T14:01:59.788Z" }, + { url = "https://files.pythonhosted.org/packages/38/ca/d0a02045d445cbf1029d65f01b487fdded5b333c0367a8bae0565b3def00/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780", size = 128577, upload-time = "2025-06-06T14:02:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/e7/7d3f2a4a442f264e05cab2bdf20ed1b95cb3f753bd1b0f277f2b49fb8335/winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78", size = 127787, upload-time = "2025-06-06T14:02:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2f/cc36f475f8af293f40e2c2a5d6c2e75a189c2c2d4d01ecb3551578518c79/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc", size = 131849, upload-time = "2025-06-06T14:02:03.09Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/896fb734f7456910ec412f3f3adfdc3f0dc3134864a496d5b120592f3bfd/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307", size = 128144, upload-time = "2025-06-06T14:02:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d2/24d9f59bdc05e741261d5bec3bcea9a848d57714126a263df840e2b515a8/winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163", size = 127774, upload-time = "2025-06-06T14:02:04.752Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/601724453b885265c7779d5f8025b043a68447cbc64ceb9149d674d5b724/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915", size = 131827, upload-time = "2025-06-06T14:02:05.601Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/a419675a6087c9ea496968c9b7805ef234afa585b7483e2269608a12b044/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d", size = 128180, upload-time = "2025-06-06T14:02:06.759Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/2869ea2112c565caace73c9301afd1d7afcc49bdd37fac058f0178ba95d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee", size = 131701, upload-time = "2025-09-20T07:17:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/aae50b1d0e37b5a61055759aedd42c6c99d7c17ab8c3e568ab33c0288938/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0", size = 135566, upload-time = "2025-09-20T07:17:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c3/6d3ce7a58e6c828e0795c9db8790d0593dd7fdf296e513c999150deb98d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987", size = 134393, upload-time = "2025-09-20T07:17:18.802Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index 1a9dafc8f3..639b06db3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,12 @@ build-backend = "setuptools.build_meta" include-package-data = false [tool.setuptools.packages.find] -where = [".", "packages/dimos-runtime-protocol/src"] -include = ["dimos*", "dimos_runtime_protocol*"] +where = [ + ".", + "packages/dimos-runtime-protocol/src", + "packages/dimos-gpd-grasp-demo/src", +] +include = ["dimos*", "dimos_runtime_protocol*", "dimos_gpd_grasp_demo*"] # Web frontends / vendored node_modules: not part of the Python runtime # (MANIFEST.in already prunes these from the sdist; this keeps their stray # .py modules out of the wheel too). @@ -607,6 +611,8 @@ exclude_also = [ max_size_kb = 50 ignore = [ "uv.lock", + "packages/dimos-gpd-grasp-demo/pixi.lock", + "packages/dimos-gpd-grasp-demo/uv.lock", "packages/dimos-gpd-worker-demo/pixi.lock", "packages/dimos-gpd-worker-demo/uv.lock", "*/package-lock.json", From dfc7d794e4de23e39860082ce98a9ad0f5282294 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 29 Jun 2026 20:29:10 -0700 Subject: [PATCH 102/110] spec: dynamic module io --- CONTEXT.md | 8 + .../0002-configuration-resolved-module-io.md | 7 + .../.openspec.yaml | 2 + .../design.md | 162 ++++++++++++++++++ .../proposal.md | 47 +++++ .../configuration-resolved-module-io/spec.md | 66 +++++++ .../configuration-resolved-module-io/tasks.md | 43 +++++ 7 files changed, 335 insertions(+) create mode 100644 docs/adr/0002-configuration-resolved-module-io.md create mode 100644 openspec/changes/configuration-resolved-module-io/.openspec.yaml create mode 100644 openspec/changes/configuration-resolved-module-io/design.md create mode 100644 openspec/changes/configuration-resolved-module-io/proposal.md create mode 100644 openspec/changes/configuration-resolved-module-io/specs/configuration-resolved-module-io/spec.md create mode 100644 openspec/changes/configuration-resolved-module-io/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md index 604e4620e3..28e9583d7e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -124,6 +124,14 @@ _Avoid_: full optional dependency set, application module package, public remote A lightweight coordinator-visible DimOS Module class that declares the streams, module references, RPC surface, and config expectations used for blueprint wiring. _Avoid_: heavy implementation module, connection-only shim, runtime sidecar +**Module IO contract**: +The coordinator-visible set of typed stream inputs and outputs a DimOS Module exposes for blueprint wiring. +_Avoid_: stream schema, port list, dynamic ports + +**Configuration-resolved module IO**: +A module IO contract whose streams are determined from the module's final configuration before blueprint wiring. +_Avoid_: runtime dynamic IO, late-bound ports, generated subclass IO + **Module implementation descriptor**: A portable identity for the concrete Module implementation class that a venv module worker imports and instantiates for a module contract. _Avoid_: coordinator-imported heavy class, pickled module class, alternate stream contract diff --git a/docs/adr/0002-configuration-resolved-module-io.md b/docs/adr/0002-configuration-resolved-module-io.md new file mode 100644 index 0000000000..d33577c308 --- /dev/null +++ b/docs/adr/0002-configuration-resolved-module-io.md @@ -0,0 +1,7 @@ +# Resolve configurable Module IO at build/load time + +DimOS will support configuration-resolved Module IO: a Module may derive its coordinator-visible typed stream inputs and outputs from its final validated config before blueprint wiring. The default `Module.io_contract(config)` remains annotation-derived, so existing annotated `In`/`Out` modules keep their behavior. A custom `io_contract` override is a complete replacement for annotation-derived IO. + +Configuration-resolved streams are fixed for a running module. They are stored in the Module's input/output registries and exposed through `self.inputs` and `self.outputs`; static annotated streams keep attribute access for backward compatibility. Blueprint remappings apply externally to resolved stream names and do not rename module-local keys. + +This avoids runtime mutable ports while allowing policy, backend, task, and hardware configuration to shape the graph honestly. Runtime hot-add/remove streams, stream groups, separate wire names, and coordinator/worker contract fingerprinting are deferred until a concrete need appears. diff --git a/openspec/changes/configuration-resolved-module-io/.openspec.yaml b/openspec/changes/configuration-resolved-module-io/.openspec.yaml new file mode 100644 index 0000000000..d6b53dee55 --- /dev/null +++ b/openspec/changes/configuration-resolved-module-io/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/configuration-resolved-module-io/design.md b/openspec/changes/configuration-resolved-module-io/design.md new file mode 100644 index 0000000000..26963d5d7d --- /dev/null +++ b/openspec/changes/configuration-resolved-module-io/design.md @@ -0,0 +1,162 @@ +## Context + +DimOS modules currently declare typed IO with class annotations such as `image: In[Image]` and `command: Out[JointState]`. `BlueprintAtom.create()` discovers those annotations before CLI/config-file overrides are merged. Later, worker deployment merges `blueprint_args` into module kwargs, instantiates modules, and the coordinator wires the already-discovered streams. + +That ordering works for static modules, but it cannot represent modules whose IO contract depends on final validated configuration. Examples include policy modules whose observation/action streams depend on the selected policy contract, and coordinators whose externally visible commands depend on selected tasks and hardware adapters. + +This design introduces configuration-resolved module IO: a module's coordinator-visible stream contract is resolved from final validated module config at build/load time, before wiring, and remains fixed for the running module. + +## Goals / Non-Goals + +**Goals:** + +- Preserve existing annotation-based module IO behavior by default. +- Let modules override `Module.io_contract(config)` to return a complete configuration-derived IO contract. +- Resolve IO after all module kwargs, CLI/config-file `blueprint_args`, and global config are merged and validated. +- Use the same resolved stream names for validation, remapping, conflict checks, and transport assignment. +- Expose configuration-resolved streams through `self.inputs` and `self.outputs`. +- Keep annotated stream attributes working for backward compatibility. +- Validate stream-name uniqueness and stale remappings before deployment. +- Include unit tests and a runnable teaching demo as implementation gates. + +**Non-Goals:** + +- No runtime hot-add/remove streams after a module is built or loaded. +- No stream groups in v1. +- No separate `wire_name` field in v1; blueprint remappings remain the external rename mechanism. +- No parent/worker IO contract fingerprint validation in v1. +- No immediate conversion of `ControlCoordinator`; this change proves the core mechanism first. + +## Decisions + +### Add a public module IO contract API + +Introduce public declaration types along these lines: + +```python +StreamDirection = Literal["in", "out"] + +@dataclass(frozen=True) +class StreamDecl: + name: str + direction: StreamDirection + type: type + +@dataclass(frozen=True) +class ModuleIOContract: + streams: tuple[StreamDecl, ...] +``` + +`ModuleIOContract` owns contract validation, including uniqueness of stream names across both directions. Blueprint resolution calls the same validation logic rather than duplicating rules. + +Alternative considered: return internal `StreamRef` values directly. Rejected because module authors need a public API that can evolve separately from coordinator internals. + +### Always resolve IO through `Module.io_contract(config)` + +Add a classmethod on `Module`: + +```python +@classmethod +def io_contract(cls, config: ModuleConfig) -> ModuleIOContract: + return ModuleIOContract.from_annotations(cls) +``` + +The base implementation preserves existing behavior. Overrides are complete replacements; annotations are not merged automatically. An override can return zero streams even if the class has annotated `In` or `Out` fields. + +Alternative considered: detect whether a module overrides the hook and branch. Rejected because always calling the hook gives a simpler and more uniform path. + +### Resolve final module config before stream discovery + +Build/load should create a resolved module plan: + +```text +BlueprintAtom.kwargs + blueprint_args + GlobalConfig + -> validated module config + -> Module.io_contract(config) + -> ResolvedModulePlan + -> validation/remapping/conflict checks + -> worker deployment with final kwargs + -> transport wiring +``` + +`BlueprintAtom` remains authoring-time data. A new internal resolved layer becomes authoritative for wiring. + +The worker deployment path should receive final per-module kwargs. It should no longer be the first place where `blueprint_args` are merged, because that would let worker instantiation diverge from coordinator-side IO resolution. + +### Use explicit stream registries as the module source of truth + +`Module.inputs` and `Module.outputs` should read from explicit internal registries. Annotated streams are still installed as attributes for compatibility and also registered. Configuration-resolved streams are registered but not installed as arbitrary attributes. + +This means module authors use: + +```python +self.inputs["primary_camera"] +self.outputs["policy_action"] +``` + +for configuration-resolved streams. + +`set_transport()` and `connect_stream()` should resolve streams through those registries, not through `getattr()`. + +Alternative considered: create dynamic attributes for all resolved streams. Rejected because arbitrary config-provided names can collide with lifecycle methods, `config`, `tf`, RPC helpers, or internal fields. + +### Keep remapping external-only + +Remappings apply to resolved stream names, but they do not rename keys inside `self.inputs` or `self.outputs`. + +Example: + +```text +Local key: primary_camera +Blueprint remap: primary_camera -> zed_front_rgb +Graph name: zed_front_rgb +Module access: self.inputs["primary_camera"] +``` + +Blueprint resolution MUST fail when a remapping references a stream not present in the resolved `ModuleIOContract`. + +### Preserve v1 graph conflict semantics + +After remapping, current graph semantics remain: + +```text +same graph name + same message type => shared transport/autoconnect group +same graph name + different message type => error +``` + +Changing fan-in/fan-out policy, adding single-producer control-stream checks, or introducing stricter direction-aware rules is out of scope for v1. + +### Prove with a demo before broader migrations + +The first user-facing conversion is a small teaching example under `examples/`. The demo should show config changing IO shape, not just stream names. For example, a module whose config mode exposes either an image input or a joint-state input. + +The implementation gate is that the demo runs successfully, in addition to core unit tests passing. + +## Risks / Trade-offs + +- Config-derived IO can be nondeterministic if authors depend on mutable external state → Document that IO is fixed after build/load and that hook determinism is author responsibility. +- Parent and worker could resolve different IO if config merging remains split → Resolve final kwargs before deployment and pass those final kwargs to the worker. +- Dynamic stream names can collide within a module → Validate uniqueness across the entire `ModuleIOContract`. +- Existing callers may expect `inputs`/`outputs` to scan instance attributes → Preserve public `inputs`/`outputs` behavior while changing their backing source to registries. +- Class-level introspection without config cannot show configuration-resolved IO → Build/load graph rendering should use the resolved plan; class-level introspection remains best-effort/default-oriented. +- Native modules may depend on declared ports before subprocess launch → Ensure resolved streams are instantiated before native module startup; deeper native-specific behavior can be handled after the core slice. + +## Migration Plan + +1. Add `StreamDecl`, `ModuleIOContract`, and shared validation. +2. Add `Module.io_contract(config)` with annotation-derived default behavior. +3. Add explicit stream registries and keep annotated stream attributes as compatibility aliases. +4. Update `inputs`, `outputs`, `set_transport()`, and `connect_stream()` to use registries. +5. Add resolved module planning in build/load before stream conflict checks and deployment. +6. Pass final per-module kwargs to worker deployment instead of late-merging `blueprint_args` in the worker manager. +7. Validate remappings against resolved stream names. +8. Add unit tests for default annotation behavior, custom replacement behavior, stream uniqueness, remapping validation, and final-config resolution. +9. Add the `examples/` teaching demo and make it a required implementation gate. + +Rollback strategy: because annotation-derived IO remains the default, the core change can be rolled back by removing custom contract usage and returning to annotation discovery for all modules. + +## Open Questions + +- Exact module/file placement for `StreamDecl` and `ModuleIOContract`. +- Whether native-module demos need a follow-up example after the core example. +- Whether future work should add stream groups or contract fingerprints. diff --git a/openspec/changes/configuration-resolved-module-io/proposal.md b/openspec/changes/configuration-resolved-module-io/proposal.md new file mode 100644 index 0000000000..3421dd6658 --- /dev/null +++ b/openspec/changes/configuration-resolved-module-io/proposal.md @@ -0,0 +1,47 @@ +## Why + +DimOS module IO is currently discovered from class annotations before final module configuration is known. That prevents modules whose stream surface depends on a selected policy, backend, task set, or hardware adapter from exposing an honest blueprint-visible IO contract. + +Configuration-resolved module IO lets a module derive its typed streams from final validated config before wiring, while preserving the existing static annotation behavior for ordinary modules. + +## What Changes + +- Add a public module IO contract API made of `ModuleIOContract` and `StreamDecl`. +- Add `Module.io_contract(config)` as the single source for module stream declarations. +- Keep annotation-derived IO as the default implementation for modules that do not override `io_contract`. +- Treat custom `io_contract` overrides as complete replacements for annotation-derived IO. +- Resolve module IO after blueprint kwargs, CLI/config-file `blueprint_args`, and global config are merged into validated module config. +- Add an internal resolved module plan used for build/load-time stream validation and wiring. +- Store streams in explicit internal input/output registries so `self.inputs` and `self.outputs` work for both annotated and configuration-resolved streams. +- Keep annotated stream attributes for backward compatibility, but do not create arbitrary attributes for configuration-resolved streams. +- Update wiring helpers to resolve streams through `self.inputs` and `self.outputs` rather than `getattr`. +- Validate that stream names are unique across each module IO contract. +- Validate remappings against the resolved IO contract and fail when they reference missing streams. +- Preserve existing graph conflict/autoconnect semantics after remapping. +- Add a small teaching example under `examples/` demonstrating config that changes IO shape. +- Add unit tests for the core behavior. + +## Capabilities + +### New Capabilities +- `configuration-resolved-module-io`: Modules can expose a build/load-time IO contract derived from final validated module configuration. + +### Modified Capabilities + +None. + +## Impact + +- Affected core areas: + - `dimos/core/module.py` + - `dimos/core/stream.py` if shared stream declaration types belong there + - `dimos/core/coordination/blueprints.py` + - `dimos/core/coordination/module_coordinator.py` + - Python worker deployment path that currently merges `blueprint_args` late +- Affected user-facing APIs: + - New `Module.io_contract(config)` override point + - New `ModuleIOContract` and `StreamDecl` types + - Existing annotation-based module authoring remains supported +- Validation gate: + - Unit tests for core resolution/wiring behavior must pass. + - The teaching demo in `examples/` must run successfully as an implementation gate. diff --git a/openspec/changes/configuration-resolved-module-io/specs/configuration-resolved-module-io/spec.md b/openspec/changes/configuration-resolved-module-io/specs/configuration-resolved-module-io/spec.md new file mode 100644 index 0000000000..fadfa19bec --- /dev/null +++ b/openspec/changes/configuration-resolved-module-io/specs/configuration-resolved-module-io/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: Default annotation IO remains supported +The system SHALL preserve existing annotation-derived module IO behavior for modules that do not override `Module.io_contract(config)`. + +#### Scenario: Static annotated module resolves IO +- **WHEN** a module declares `In` or `Out` streams with class annotations and does not override `io_contract` +- **THEN** the resolved module IO contract contains the same streams that current annotation discovery would expose + +### Requirement: Modules can return configuration-resolved IO contracts +The system SHALL allow a module to override `Module.io_contract(config)` and return a complete `ModuleIOContract` derived from final validated module config. + +#### Scenario: Config changes IO shape +- **WHEN** a module's final validated config selects one IO mode instead of another +- **THEN** blueprint wiring uses the streams returned by `io_contract(config)` for that selected mode + +#### Scenario: Custom contract replaces annotations +- **WHEN** a module overrides `io_contract(config)` and also has annotated `In` or `Out` fields +- **THEN** blueprint wiring uses only the streams returned by the override + +### Requirement: IO resolves after final config merge +The system SHALL resolve module IO after blueprint kwargs, CLI/config-file module overrides, and global config have been merged into validated module config. + +#### Scenario: CLI override changes resolved streams +- **WHEN** a module's blueprint kwargs select one IO mode and a build/load override selects another IO mode +- **THEN** the resolved IO contract reflects the build/load override + +### Requirement: Dynamic streams use input and output registries +The system SHALL expose resolved streams through `self.inputs` and `self.outputs` for both annotated and configuration-resolved IO. + +#### Scenario: Configuration-resolved input is accessible +- **WHEN** a module resolves an input stream from `io_contract(config)` without an annotated attribute +- **THEN** module code can access that stream through `self.inputs[stream_name]` + +#### Scenario: Annotated stream attributes remain compatible +- **WHEN** a module declares annotated stream attributes +- **THEN** those attributes remain available while the streams are also present in `self.inputs` or `self.outputs` + +### Requirement: Stream names are unique within each module IO contract +The system SHALL reject a `ModuleIOContract` that contains duplicate stream names, even when duplicate names have different directions. + +#### Scenario: Duplicate input and output names are rejected +- **WHEN** a module IO contract declares an input named `joint_state` and an output named `joint_state` +- **THEN** validation fails before blueprint wiring + +### Requirement: Remappings validate against resolved streams +The system SHALL apply stream remappings to resolved stream names and reject remappings whose local stream name is absent from the resolved module IO contract. + +#### Scenario: Valid remapping keeps module-local key +- **WHEN** a resolved input named `primary_camera` is remapped to graph name `zed_front_rgb` +- **THEN** the graph connects using `zed_front_rgb` and module code still accesses `self.inputs["primary_camera"]` + +#### Scenario: Stale remapping fails +- **WHEN** a blueprint remapping references a stream name not present in the resolved IO contract +- **THEN** blueprint resolution fails before deployment + +### Requirement: Existing graph conflict semantics remain unchanged +The system SHALL preserve current graph conflict and autoconnect semantics after applying remappings to resolved stream names. + +#### Scenario: Same graph name and same type autoconnect +- **WHEN** multiple resolved streams share the same graph name and message type after remapping +- **THEN** the coordinator uses the existing shared-transport autoconnect behavior + +#### Scenario: Same graph name and different type fails +- **WHEN** multiple resolved streams share the same graph name with different message types after remapping +- **THEN** the coordinator rejects the blueprint using the existing conflict behavior diff --git a/openspec/changes/configuration-resolved-module-io/tasks.md b/openspec/changes/configuration-resolved-module-io/tasks.md new file mode 100644 index 0000000000..95fe456931 --- /dev/null +++ b/openspec/changes/configuration-resolved-module-io/tasks.md @@ -0,0 +1,43 @@ +## 1. Core IO Contract API + +- [ ] 1.1 Add public `StreamDecl` and `ModuleIOContract` types with stream-name uniqueness validation. +- [ ] 1.2 Add shared validation helpers so construction-time and resolution-time checks use the same rules. +- [ ] 1.3 Add default `Module.io_contract(config)` that returns annotation-derived IO. +- [ ] 1.4 Add tests proving annotation-only modules resolve the same streams as before. + +## 2. Module Stream Registries + +- [ ] 2.1 Add explicit internal input/output registries to `Module`. +- [ ] 2.2 Register annotated streams in the registries while preserving existing annotated attributes. +- [ ] 2.3 Instantiate custom configuration-resolved streams into registries without creating arbitrary attributes. +- [ ] 2.4 Update `inputs`, `outputs`, `set_transport()`, `connect_stream()`, and related stream lookup paths to use registries. +- [ ] 2.5 Add tests proving configuration-resolved streams are available through `self.inputs` and `self.outputs`. + +## 3. Resolved Blueprint Planning + +- [ ] 3.1 Add an internal resolved module plan that stores final module config, final kwargs, and resolved IO contract. +- [ ] 3.2 Resolve final per-module config before stream conflict checks, deployment, and wiring in `build()`. +- [ ] 3.3 Resolve final per-module config before stream conflict checks, deployment, and wiring in blueprint load paths. +- [ ] 3.4 Pass final per-module kwargs into worker deployment instead of late-merging `blueprint_args` inside the worker manager. +- [ ] 3.5 Add tests proving CLI/build overrides affect resolved IO before wiring. + +## 4. Remapping and Conflict Validation + +- [ ] 4.1 Validate remappings against resolved stream names. +- [ ] 4.2 Fail blueprint resolution when a remapping references a stream absent from the resolved IO contract. +- [ ] 4.3 Preserve existing same-name/same-type autoconnect behavior after remapping. +- [ ] 4.4 Preserve existing same-name/different-type conflict behavior after remapping. +- [ ] 4.5 Add tests for valid remapping, stale remapping, autoconnect, and type-conflict scenarios. + +## 5. Teaching Demo + +- [ ] 5.1 Add `examples/configuration_resolved_io.py` demonstrating a module whose config selects between two different IO shapes. +- [ ] 5.2 Document how to run the demo in the example file or nearby README text. +- [ ] 5.3 Add a verification command for the demo and ensure it passes locally. + +## 6. Verification Gates + +- [ ] 6.1 Run the focused configuration-resolved IO unit tests. +- [ ] 6.2 Run the existing affected core blueprint/module tests. +- [ ] 6.3 Run the teaching demo successfully. +- [ ] 6.4 Run the standard project test command if practical for the implementation scope. From a0d8733687307aa5d52b1b63e56e018e89c92372 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 29 Jun 2026 22:10:33 -0700 Subject: [PATCH 103/110] feat: add configuration-resolved module io --- dimos/core/coordination/blueprints.py | 41 ++-- dimos/core/coordination/module_coordinator.py | 198 +++++++++++++----- dimos/core/coordination/test_blueprints.py | 86 +++++++- .../coordination/test_module_coordinator.py | 55 ++++- dimos/core/coordination/test_worker.py | 3 +- dimos/core/coordination/worker_manager.py | 3 +- .../coordination/worker_manager_python.py | 7 +- dimos/core/introspection/blueprint/dot.py | 11 +- dimos/core/module.py | 174 +++++++++++---- dimos/core/native_module.py | 5 +- examples/configuration_resolved_io.py | 128 +++++++++++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../configuration-resolved-module-io/spec.md | 0 .../tasks.md | 45 ++++ .../configuration-resolved-module-io/tasks.md | 43 ---- .../configuration-resolved-module-io/spec.md | 69 ++++++ 18 files changed, 689 insertions(+), 179 deletions(-) create mode 100644 examples/configuration_resolved_io.py rename openspec/changes/{configuration-resolved-module-io => archive/2026-06-30-configuration-resolved-module-io}/.openspec.yaml (100%) rename openspec/changes/{configuration-resolved-module-io => archive/2026-06-30-configuration-resolved-module-io}/design.md (100%) rename openspec/changes/{configuration-resolved-module-io => archive/2026-06-30-configuration-resolved-module-io}/proposal.md (100%) rename openspec/changes/{configuration-resolved-module-io => archive/2026-06-30-configuration-resolved-module-io}/specs/configuration-resolved-module-io/spec.md (100%) create mode 100644 openspec/changes/archive/2026-06-30-configuration-resolved-module-io/tasks.md delete mode 100644 openspec/changes/configuration-resolved-module-io/tasks.md create mode 100644 openspec/specs/configuration-resolved-module-io/spec.md diff --git a/dimos/core/coordination/blueprints.py b/dimos/core/coordination/blueprints.py index f21ff3fe30..c759b48355 100644 --- a/dimos/core/coordination/blueprints.py +++ b/dimos/core/coordination/blueprints.py @@ -27,7 +27,12 @@ from dimos.protocol.service.system_configurator.base import SystemConfigurator from dimos.core.global_config import GlobalConfig -from dimos.core.module import ModuleBase, is_module_type +from dimos.core.module import ( + ModuleBase, + ModuleIOContract, + is_module_type, + resolve_module_type_hints, +) from dimos.core.stream import In, Out from dimos.core.transport import PubSubTransport from dimos.spec.utils import Spec, is_spec @@ -88,37 +93,21 @@ class BlueprintAtom: @classmethod def create(cls, module: type[ModuleBase], kwargs: dict[str, Any]) -> Self: - streams: list[StreamRef] = [] module_refs: list[ModuleRef] = [] - # Resolve annotations using namespaces from the full MRO chain so that - # In/Out behind TYPE_CHECKING + `from __future__ import annotations` work. - # Iterate reversed MRO so the most specific class's namespace wins when - # parent modules shadow names (e.g. spec.perception.Image vs sensor_msgs.Image). - globalns: dict[str, Any] = {} - for c in reversed(module.__mro__): - if c.__module__ in sys.modules: - globalns.update(sys.modules[c.__module__].__dict__) - try: - all_annotations = get_type_hints(module, globalns=globalns) - except Exception: - # Fallback to raw annotations if get_type_hints fails. - all_annotations = {} - for base_class in reversed(module.__mro__): - if hasattr(base_class, "__annotations__"): - all_annotations.update(base_class.__annotations__) + annotation_contract = ModuleIOContract.from_annotations(module) + streams = [ + StreamRef(name=stream.name, type=stream.type, direction=stream.direction) + for stream in annotation_contract.streams + ] + all_annotations = resolve_module_type_hints(module) for name, annotation in all_annotations.items(): origin = get_origin(annotation) - # Streams - if origin in (In, Out): - direction = "in" if origin == In else "out" - type_ = get_args(annotation)[0] - streams.append( - StreamRef(name=name, type=type_, direction=direction) # type: ignore[arg-type] - ) # linking to unknown module via Spec - elif is_spec(annotation): + if origin in (In, Out): + continue + if is_spec(annotation): module_refs.append(ModuleRef(name=name, spec=annotation)) # linking to specific/known module directly elif is_module_type(annotation): diff --git a/dimos/core/coordination/module_coordinator.py b/dimos/core/coordination/module_coordinator.py index a560152133..0302414f82 100644 --- a/dimos/core/coordination/module_coordinator.py +++ b/dimos/core/coordination/module_coordinator.py @@ -16,6 +16,7 @@ from collections import defaultdict from collections.abc import Callable, Mapping, MutableMapping +from dataclasses import dataclass import importlib import inspect import shutil @@ -27,7 +28,7 @@ from dimos.core.coordination.worker_manager import WorkerManager from dimos.core.coordination.worker_manager_python import WorkerManagerPython from dimos.core.global_config import GlobalConfig, global_config -from dimos.core.module import ModuleBase, ModuleSpec +from dimos.core.module import ModuleBase, ModuleConfig, ModuleIOContract, ModuleSpec, StreamDecl from dimos.core.resource import Resource from dimos.core.transport import LCMTransport, PubSubTransport, pLCMTransport from dimos.spec.utils import is_spec, spec_annotation_compliance, spec_structural_compliance @@ -50,6 +51,19 @@ class ModuleDescriptor(NamedTuple): rpc_names: list[str] +@dataclass(frozen=True) +class ResolvedModulePlan: + atom: BlueprintAtom + module: type[ModuleBase] + final_kwargs: dict[str, Any] + config: ModuleConfig + io_contract: ModuleIOContract + + @property + def streams(self) -> tuple[StreamDecl, ...]: + return self.io_contract.streams + + class ModuleCoordinator(Resource): _managers: dict[str, WorkerManager] _global_config: GlobalConfig @@ -64,6 +78,7 @@ def __init__( self._managers = {cls.deployment_identifier: cls(g=g) for cls in manager_types} self._deployed_modules = {} self._deployed_atoms: dict[type[ModuleBase], BlueprintAtom] = {} + self._resolved_module_plans: dict[type[ModuleBase], ResolvedModulePlan] = {} self._resolved_module_refs: dict[tuple[type[ModuleBase], str], type[ModuleBase]] = {} self._transport_registry: dict[tuple[str, type], PubSubTransport[Any]] = {} self._class_aliases: dict[type[ModuleBase], type[ModuleBase]] = {} @@ -173,9 +188,7 @@ def deploy( self._deployed_modules[module_class] = deployed_module return deployed_module # type: ignore[return-value] - def deploy_parallel( - self, module_specs: list[ModuleSpec], blueprint_args: Mapping[str, Mapping[str, Any]] - ) -> list[ModuleProxy]: + def deploy_parallel(self, module_specs: list[ModuleSpec]) -> list[ModuleProxy]: if not self._managers: raise ValueError("Not started") @@ -191,7 +204,7 @@ def deploy_parallel( results: list[Any] = [None] * len(module_specs) def _deploy_group(dep: str) -> None: - deployed = self._managers[dep].deploy_parallel(specs_by_deployment[dep], blueprint_args) + deployed = self._managers[dep].deploy_parallel(specs_by_deployment[dep]) for index, module in zip(indices_by_deployment[dep], deployed, strict=True): results[index] = module @@ -249,21 +262,21 @@ def _send_on_system_modules(self) -> None: if hasattr(module, "on_system_modules"): module.on_system_modules(modules) - def _connect_streams(self, blueprint: Blueprint) -> None: + def _connect_streams(self, blueprint: Blueprint, plans: tuple[ResolvedModulePlan, ...]) -> None: streams: dict[tuple[str, type], list[tuple[type, str]]] = defaultdict(list) - for bp in blueprint.active_blueprints: - for conn in bp.streams: - remapped_name = blueprint.remapping_map.get((bp.module, conn.name), conn.name) + for plan in plans: + for conn in plan.streams: + remapped_name = blueprint.remapping_map.get((plan.module, conn.name), conn.name) if isinstance(remapped_name, str): - streams[remapped_name, conn.type].append((bp.module, conn.name)) + streams[remapped_name, conn.type].append((plan.module, conn.name)) for remapped_name, stream_type in streams.keys(): key = (remapped_name, stream_type) if key in self._transport_registry: transport = self._transport_registry[key] else: - transport = _get_transport_for(blueprint, remapped_name, stream_type) + transport = _get_transport_for(blueprint, plans, remapped_name, stream_type) self._transport_registry[key] = transport for module, original_name in streams[key]: instance = self.get_instance(module) # type: ignore[assignment] @@ -289,24 +302,26 @@ def build( global_config.update(**dict(blueprint.global_config_overrides)) blueprint_args = blueprint_args or {} if "g" in blueprint_args: - global_config.update(**blueprint_args.pop("g")) + global_config.update(**dict(blueprint_args["g"])) _run_configurators(blueprint) _check_requirements(blueprint) - _verify_no_name_conflicts(blueprint) + plans = _resolve_module_plans(blueprint, global_config, blueprint_args) + _verify_stream_remappings(blueprint, plans) + _verify_no_name_conflicts(blueprint, plans) logger.info("Starting the modules") coordinator = cls(g=global_config) coordinator.start() - _deploy_all_modules(blueprint, coordinator, global_config, blueprint_args) - coordinator._connect_streams(blueprint) + _deploy_all_modules(plans, coordinator, global_config) + coordinator._connect_streams(blueprint, plans) _connect_module_refs(blueprint, coordinator) coordinator.build_all_modules() coordinator.start_all_modules() - _log_blueprint_graph(blueprint, coordinator) + _log_blueprint_graph(blueprint, coordinator, plans) return coordinator @@ -336,7 +351,7 @@ def _load_blueprint( self._global_config.update(**dict(blueprint.global_config_overrides)) blueprint_args = blueprint_args or {} if "g" in blueprint_args: - self._global_config.update(**blueprint_args.pop("g")) + self._global_config.update(**dict(blueprint_args["g"])) # Scale worker pool. n_extra = int(blueprint.global_config_overrides.get("n_workers", 0)) @@ -348,8 +363,10 @@ def _load_blueprint( _run_configurators(blueprint) _check_requirements(blueprint) - _verify_no_name_conflicts(blueprint) - _verify_no_conflicts_with_existing(blueprint, self._transport_registry) + plans = _resolve_module_plans(blueprint, self._global_config, blueprint_args) + _verify_stream_remappings(blueprint, plans) + _verify_no_name_conflicts(blueprint, plans) + _verify_no_conflicts_with_existing(blueprint, self._transport_registry, plans) # Reject duplicate modules. for bp in blueprint.active_blueprints: @@ -360,8 +377,8 @@ def _load_blueprint( before = set(self._deployed_modules) - _deploy_all_modules(blueprint, self, self._global_config, blueprint_args) - self._connect_streams(blueprint) + _deploy_all_modules(plans, self, self._global_config) + self._connect_streams(blueprint, plans) _connect_module_refs(blueprint, self, existing_modules=before) new_modules = [proxy for cls, proxy in self._deployed_modules.items() if cls not in before] @@ -423,6 +440,7 @@ def _unload_module(self, module_class: type[ModuleBase]) -> None: del self._deployed_modules[module_class] self._deployed_atoms.pop(module_class, None) + self._resolved_module_plans.pop(module_class, None) self._module_transports.pop(module_class, None) self._class_aliases = { k: v for k, v in self._class_aliases.items() if v is not module_class @@ -477,8 +495,9 @@ def _restart_module( f"restart_module only supports python deployment, got {module_class.deployment!r}" ) - old_atom = self._deployed_atoms[module_class] - kwargs = dict(old_atom.kwargs) + old_plan = self._resolved_module_plans[module_class] + kwargs = dict(old_plan.final_kwargs) + kwargs["g"] = self._global_config saved_transports = dict(self._module_transports.get(module_class, {})) inbound_refs = [ (consumer, ref_name) @@ -515,13 +534,22 @@ def _restart_module( new_bp = new_class.blueprint(**kwargs) new_atom = new_bp.active_blueprints[0] self._deployed_atoms[new_class] = new_atom + config = new_class.resolve_config(kwargs) + new_plan = ResolvedModulePlan( + atom=new_atom, + module=new_class, + final_kwargs=kwargs, + config=config, + io_contract=new_class.io_contract(config), + ) + self._resolved_module_plans[new_class] = new_plan - for stream_ref in new_atom.streams: + for stream_ref in new_plan.streams: transport = saved_transports.get(stream_ref.name) if transport is not None: new_proxy.set_transport(stream_ref.name, transport) self._module_transports[new_class] = { - s.name: t for s in new_atom.streams if (t := saved_transports.get(s.name)) is not None + s.name: t for s in new_plan.streams if (t := saved_transports.get(s.name)) is not None } for consumer_class, ref_name in inbound_refs: @@ -557,41 +585,101 @@ def loop(self) -> None: self.stop() -def _all_name_types(blueprint: Blueprint) -> set[tuple[str, type]]: - result = set() +def _resolve_module_plans( + blueprint: Blueprint, + gc: GlobalConfig, + blueprint_args: Mapping[str, Any], +) -> tuple[ResolvedModulePlan, ...]: + plans: list[ResolvedModulePlan] = [] for bp in blueprint.active_blueprints: - for conn in bp.streams: - remapped_name = blueprint.remapping_map.get((bp.module, conn.name), conn.name) + module_overrides = blueprint_args.get(bp.module.name, {}) + if module_overrides is None: + module_overrides = {} + if not isinstance(module_overrides, Mapping): + raise TypeError( + f"Blueprint args for {bp.module.name} must be a mapping, got " + f"{type(module_overrides).__name__}" + ) + final_kwargs = {**bp.kwargs, **dict(module_overrides)} + final_kwargs["g"] = gc + config = bp.module.resolve_config(final_kwargs) + io_contract = bp.module.io_contract(config) + plans.append( + ResolvedModulePlan( + atom=bp, + module=bp.module, + final_kwargs=final_kwargs, + config=config, + io_contract=io_contract, + ) + ) + return tuple(plans) + + +def _verify_stream_remappings(blueprint: Blueprint, plans: tuple[ResolvedModulePlan, ...]) -> None: + plans_by_module = {plan.module: plan for plan in plans} + for (module, name), replacement in blueprint.remapping_map.items(): + if not isinstance(replacement, str): + continue + plan = plans_by_module.get(module) + if plan is None: + continue + resolved_names = {stream.name for stream in plan.streams} + if name not in resolved_names: + raise ValueError( + f"Stream remapping for {module.__name__}.{name} references a stream absent " + "from the resolved IO contract" + ) + + +def _all_name_types( + blueprint: Blueprint, plans: tuple[ResolvedModulePlan, ...] | None = None +) -> set[tuple[str, type]]: + plans = plans or _resolve_module_plans(blueprint, global_config, {}) + result = set() + for plan in plans: + for conn in plan.streams: + remapped_name = blueprint.remapping_map.get((plan.module, conn.name), conn.name) if isinstance(remapped_name, str): result.add((remapped_name, conn.type)) return result -def _is_name_unique(blueprint: Blueprint, name: str) -> bool: - return sum(1 for n, _ in _all_name_types(blueprint) if n == name) == 1 +def _is_name_unique(blueprint: Blueprint, plans: tuple[ResolvedModulePlan, ...], name: str) -> bool: + return sum(1 for n, _ in _all_name_types(blueprint, plans) if n == name) == 1 -def _get_transport_for(blueprint: Blueprint, name: str, stream_type: type) -> PubSubTransport[Any]: +def _get_transport_for( + blueprint: Blueprint, + plans: tuple[ResolvedModulePlan, ...], + name: str, + stream_type: type, +) -> PubSubTransport[Any]: transport = blueprint.transport_map.get((name, stream_type), None) if transport: return transport use_pickled = getattr(stream_type, "lcm_encode", None) is None - topic = f"/{name}" if _is_name_unique(blueprint, name) else f"/{short_id()}" + topic = f"/{name}" if _is_name_unique(blueprint, plans, name) else f"/{short_id()}" transport = pLCMTransport(topic) if use_pickled else LCMTransport(topic, stream_type) return transport -def _verify_no_name_conflicts(blueprint: Blueprint) -> None: +def _verify_no_name_conflicts( + blueprint: Blueprint, plans: tuple[ResolvedModulePlan, ...] | None = None +) -> None: + plans = plans or _resolve_module_plans(blueprint, global_config, {}) name_to_types: dict[Any, set[type]] = defaultdict(set) name_to_modules: dict[Any, list[tuple[type, type]]] = defaultdict(list) - for bp in blueprint.active_blueprints: - for conn in bp.streams: - stream_name = blueprint.remapping_map.get((bp.module, conn.name), conn.name) + for plan in plans: + for conn in plan.streams: + stream_name = blueprint.remapping_map.get((plan.module, conn.name), conn.name) + if not isinstance(stream_name, str): + continue name_to_types[stream_name].add(conn.type) - name_to_modules[stream_name].append((bp.module, conn.type)) + name_to_modules[stream_name].append((plan.module, conn.type)) conflicts: dict[Any, dict[type, list[type]]] = {} for conn_name, types in name_to_types.items(): @@ -622,6 +710,7 @@ def _verify_no_name_conflicts(blueprint: Blueprint) -> None: def _verify_no_conflicts_with_existing( blueprint: Blueprint, existing_registry: dict[tuple[str, type], PubSubTransport[Any]], + plans: tuple[ResolvedModulePlan, ...] | None = None, ) -> None: """Check that a new blueprint's streams don't conflict with already-registered transports.""" if not existing_registry: @@ -631,14 +720,15 @@ def _verify_no_conflicts_with_existing( for name, stream_type in existing_registry: existing_names[name].add(stream_type) - for bp in blueprint.active_blueprints: - for conn in bp.streams: - remapped_name = blueprint.remapping_map.get((bp.module, conn.name), conn.name) + plans = plans or _resolve_module_plans(blueprint, global_config, {}) + for plan in plans: + for conn in plan.streams: + remapped_name = blueprint.remapping_map.get((plan.module, conn.name), conn.name) if isinstance(remapped_name, str) and remapped_name in existing_names: for existing_type in existing_names[remapped_name]: if existing_type != conn.type: raise ValueError( - f"Stream '{remapped_name}' in {bp.module.__name__} has type " + f"Stream '{remapped_name}' in {plan.module.__name__} has type " f"{conn.type.__module__}.{conn.type.__name__} but an existing " f"transport uses {existing_type.__module__}.{existing_type.__name__}" ) @@ -678,19 +768,19 @@ def _check_requirements(blueprint: Blueprint) -> None: def _deploy_all_modules( - blueprint: Blueprint, + plans: tuple[ResolvedModulePlan, ...], module_coordinator: ModuleCoordinator, gc: GlobalConfig, - blueprint_args: Mapping[str, Mapping[str, Any]], ) -> None: module_specs: list[ModuleSpec] = [] - for bp in blueprint.active_blueprints: - module_specs.append((bp.module, gc, bp.kwargs.copy())) + for plan in plans: + module_specs.append((plan.module, gc, plan.final_kwargs.copy())) - module_coordinator.deploy_parallel(module_specs, blueprint_args) + module_coordinator.deploy_parallel(module_specs) - for bp in blueprint.active_blueprints: - module_coordinator._deployed_atoms[bp.module] = bp + for plan in plans: + module_coordinator._deployed_atoms[plan.module] = plan.atom + module_coordinator._resolved_module_plans[plan.module] = plan def _ref_msg(module_name: str, ref: object, spec_name: str, detail: str) -> str: @@ -870,7 +960,11 @@ def _async_methods_of_spec(spec: Any) -> frozenset[str]: return frozenset(names) -def _log_blueprint_graph(blueprint: Blueprint, module_coordinator: ModuleCoordinator) -> None: +def _log_blueprint_graph( + blueprint: Blueprint, + module_coordinator: ModuleCoordinator, + plans: tuple[ResolvedModulePlan, ...], +) -> None: """Log the module graph to Rerun if a RerunBridgeModule is active.""" from dimos.visualization.rerun.bridge import RerunBridgeModule @@ -886,8 +980,8 @@ def _log_blueprint_graph(blueprint: Blueprint, module_coordinator: ModuleCoordin try: from dimos.core.introspection.blueprint.dot import render - dot_code = render(blueprint) - module_names = [bp.module.__name__ for bp in blueprint.active_blueprints] + dot_code = render(blueprint, plans) + module_names = [plan.module.__name__ for plan in plans] bridge = module_coordinator.get_instance(RerunBridgeModule) # type: ignore[arg-type] bridge.log_blueprint_graph(dot_code, module_names) except Exception: diff --git a/dimos/core/coordination/test_blueprints.py b/dimos/core/coordination/test_blueprints.py index d8ada76b20..4e4eb8ca76 100644 --- a/dimos/core/coordination/test_blueprints.py +++ b/dimos/core/coordination/test_blueprints.py @@ -34,7 +34,7 @@ autoconnect, ) from dimos.core.core import rpc -from dimos.core.module import Module +from dimos.core.module import Module, ModuleConfig, ModuleIOContract, StreamDecl from dimos.core.stream import In, Out from dimos.core.transport import LCMTransport from dimos.spec.utils import Spec @@ -98,6 +98,75 @@ def test_get_connection_set() -> None: ) +def test_default_io_contract_matches_blueprint_atom_streams() -> None: + atom = BlueprintAtom.create(CatModule, kwargs={}) + + assert ( + tuple( + StreamRef(name=stream.name, type=stream.type, direction=stream.direction) + for stream in CatModule.io_contract(CatModule.resolve_config({})).streams + ) + == atom.streams + ) + + +def test_module_io_contract_rejects_duplicate_stream_names() -> None: + with pytest.raises(ValueError, match="duplicate stream names: data"): + ModuleIOContract( + streams=( + StreamDecl(name="data", type=Data1, direction="in"), + StreamDecl(name="data", type=Data1, direction="in"), + ) + ) + + with pytest.raises(ValueError, match="duplicate stream names: data"): + ModuleIOContract( + streams=( + StreamDecl(name="data", type=Data1, direction="in"), + StreamDecl(name="data", type=Data1, direction="out"), + ) + ) + + +class EmptyContractModule(Module): + data1: In[Data1] + + @classmethod + def io_contract(cls, config: ModuleConfig) -> ModuleIOContract: + return ModuleIOContract() + + +class DynamicContractModule(Module): + annotated: In[Data1] + + @classmethod + def io_contract(cls, config: ModuleConfig) -> ModuleIOContract: + return ModuleIOContract(streams=(StreamDecl(name="dynamic", type=Data2, direction="out"),)) + + +def test_custom_io_contract_replaces_annotations_and_dynamic_streams_use_registries() -> None: + empty = EmptyContractModule() + dynamic = DynamicContractModule() + annotated = CatModule() + + try: + assert empty.inputs == {} + assert not hasattr(empty, "data1") or empty.data1 is None + + assert set(dynamic.outputs) == {"dynamic"} + assert dynamic.outputs["dynamic"].name == "dynamic" + assert not hasattr(dynamic, "dynamic") + assert dynamic.inputs == {} + assert not hasattr(dynamic, "annotated") or dynamic.annotated is None + + assert annotated.inputs["pet_cat"] is annotated.pet_cat + assert annotated.outputs["scratches"] is annotated.scratches + finally: + empty.stop() + dynamic.stop() + annotated.stop() + + def test_autoconnect() -> None: blueprint_set = autoconnect(ModuleA.blueprint(), ModuleB.blueprint()) @@ -175,6 +244,21 @@ def test_future_annotations_support() -> None: assert len(in_blueprint.streams) == 1 assert in_blueprint.streams[0] == StreamRef(name="data", type=FutureData, direction="in") + assert ( + tuple( + StreamRef(name=stream.name, type=stream.type, direction=stream.direction) + for stream in FutureModuleOut.io_contract(FutureModuleOut.resolve_config({})).streams + ) + == out_blueprint.streams + ) + assert ( + tuple( + StreamRef(name=stream.name, type=stream.type, direction=stream.direction) + for stream in FutureModuleIn.io_contract(FutureModuleIn.resolve_config({})).streams + ) + == in_blueprint.streams + ) + def test_autoconnect_merges_disabled_modules() -> None: bp_a = Blueprint( diff --git a/dimos/core/coordination/test_module_coordinator.py b/dimos/core/coordination/test_module_coordinator.py index c1baad17b2..9b89dfca1f 100644 --- a/dimos/core/coordination/test_module_coordinator.py +++ b/dimos/core/coordination/test_module_coordinator.py @@ -30,13 +30,15 @@ ModuleCoordinator, _all_name_types, _check_requirements, + _resolve_module_plans, _verify_no_conflicts_with_existing, _verify_no_name_conflicts, + _verify_stream_remappings, ) from dimos.core.coordination.worker_manager_python import WorkerManagerPython from dimos.core.core import rpc from dimos.core.global_config import GlobalConfig -from dimos.core.module import Module +from dimos.core.module import Module, ModuleConfig, ModuleIOContract, StreamDecl from dimos.core.stream import In, Out from dimos.msgs.sensor_msgs.Image import Image from dimos.spec.utils import Spec @@ -94,6 +96,21 @@ class TargetModule(Module): remapped_data: In[Data1] +class DynamicIOConfig(ModuleConfig): + emit_data2: bool = False + + +class ConfiguredIOModule(Module): + config: DynamicIOConfig # type: ignore[assignment] + + @classmethod + def io_contract(cls, config: DynamicIOConfig) -> ModuleIOContract: + stream_type = Data2 if config.emit_data2 else Data1 + return ModuleIOContract( + streams=(StreamDecl(name="configured", type=stream_type, direction="out"),) + ) + + # ModuleRef / RPC tests class CalculatorSpec(Spec, Protocol): @rpc @@ -269,6 +286,40 @@ class Module3(Module): _verify_no_name_conflicts(blueprint_set_remapped) +def test_resolved_module_plans_honor_blueprint_args_before_wiring() -> None: + blueprint_set = ConfiguredIOModule.blueprint() + + default_plans = _resolve_module_plans(blueprint_set, GlobalConfig(viewer="none"), {}) + override_plans = _resolve_module_plans( + blueprint_set, + GlobalConfig(viewer="none"), + {ConfiguredIOModule.name: {"emit_data2": True}}, + ) + + assert default_plans[0].streams == (StreamDecl(name="configured", type=Data1, direction="out"),) + assert override_plans[0].streams == ( + StreamDecl(name="configured", type=Data2, direction="out"), + ) + assert override_plans[0].final_kwargs["emit_data2"] is True + + +def test_verify_stream_remappings_uses_resolved_io_contract() -> None: + blueprint_set = ConfiguredIOModule.blueprint().remappings( + [(ConfiguredIOModule, "configured", "renamed")] + ) + plans = _resolve_module_plans(blueprint_set, GlobalConfig(viewer="none"), {}) + + _verify_stream_remappings(blueprint_set, plans) + assert _all_name_types(blueprint_set, plans) == {("renamed", Data1)} + + stale_blueprint = ConfiguredIOModule.blueprint().remappings( + [(ConfiguredIOModule, "missing", "renamed")] + ) + stale_plans = _resolve_module_plans(stale_blueprint, GlobalConfig(viewer="none"), {}) + with pytest.raises(ValueError, match="absent from the resolved IO contract"): + _verify_stream_remappings(stale_blueprint, stale_plans) + + def test_remapping() -> None: """Test that remapping streams works correctly.""" @@ -305,6 +356,8 @@ def test_remapping() -> None: # Both should have transports set assert source_instance.color_image.transport is not None assert target_instance.remapped_data.transport is not None + assert set(source_instance.outputs) == {"color_image"} + assert source_instance.outputs["color_image"].name == "color_image" # They should be using the same transport (connected) assert ( diff --git a/dimos/core/coordination/test_worker.py b/dimos/core/coordination/test_worker.py index c606dcda73..0886fdfede 100644 --- a/dimos/core/coordination/test_worker.py +++ b/dimos/core/coordination/test_worker.py @@ -162,8 +162,7 @@ def test_worker_manager_parallel_deployment(create_worker_manager): (SimpleModule, global_config, {}), (AnotherModule, global_config, {}), (ThirdModule, global_config, {}), - ], - {}, + ] ) assert len(modules) == 3 diff --git a/dimos/core/coordination/worker_manager.py b/dimos/core/coordination/worker_manager.py index f673aa21bf..e7c272a395 100644 --- a/dimos/core/coordination/worker_manager.py +++ b/dimos/core/coordination/worker_manager.py @@ -13,7 +13,7 @@ # limitations under the License. from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Protocol from dimos.core.global_config import GlobalConfig @@ -43,7 +43,6 @@ def deploy( def deploy_parallel( self, specs: Sequence[ModuleSpec], - blueprint_args: Mapping[str, Mapping[str, Any]], ) -> list[ModuleProxyProtocol]: ... def stop(self) -> None: ... diff --git a/dimos/core/coordination/worker_manager_python.py b/dimos/core/coordination/worker_manager_python.py index 4963db2dac..65188310f9 100644 --- a/dimos/core/coordination/worker_manager_python.py +++ b/dimos/core/coordination/worker_manager_python.py @@ -14,7 +14,7 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Iterable from typing import TYPE_CHECKING, Any from dimos.core.coordination.python_worker import PythonWorker @@ -135,9 +135,7 @@ def undeploy(self, proxy: ModuleProxyProtocol) -> None: self._workers.remove(target) self._n_workers = max(0, self._n_workers - 1) - def deploy_parallel( - self, specs: Iterable[ModuleSpec], blueprint_args: Mapping[str, Mapping[str, Any]] - ) -> list[ModuleProxyProtocol]: + def deploy_parallel(self, specs: Iterable[ModuleSpec]) -> list[ModuleProxyProtocol]: if self._closed: raise RuntimeError("WorkerManager is closed") @@ -161,7 +159,6 @@ def deploy_parallel( module_class, _, kwargs = specs[i] worker = self._select_worker(dedicated=module_class.dedicated_worker) worker.reserve_slot() - kwargs.update(blueprint_args.get(module_class.name, {})) workers_by_index[i] = worker assignments = [(workers_by_index[i], specs[i]) for i in range(len(specs))] diff --git a/dimos/core/introspection/blueprint/dot.py b/dimos/core/introspection/blueprint/dot.py index 3f5240690a..05d6791133 100644 --- a/dimos/core/introspection/blueprint/dot.py +++ b/dimos/core/introspection/blueprint/dot.py @@ -22,7 +22,9 @@ """ from collections import defaultdict +from collections.abc import Sequence from enum import Enum, auto +from typing import TYPE_CHECKING from dimos.core.coordination.blueprints import Blueprint from dimos.core.introspection.utils import ( @@ -34,6 +36,9 @@ from dimos.core.module import ModuleBase from dimos.utils.cli import theme +if TYPE_CHECKING: + from dimos.core.coordination.module_coordinator import ResolvedModulePlan + class LayoutAlgo(Enum): """Layout algorithms for controlling graph structure.""" @@ -53,6 +58,7 @@ class LayoutAlgo(Enum): def render( blueprint_set: Blueprint, + plans: Sequence["ResolvedModulePlan"] | None = None, *, layout: set[LayoutAlgo] | None = None, ignored_streams: set[tuple[str, str]] | None = None, @@ -87,11 +93,14 @@ def render( # Module name -> module class (for getting package info) module_classes: dict[str, type[ModuleBase]] = {} - for bp in blueprint_set.blueprints: + stream_sources = plans if plans is not None else blueprint_set.blueprints + for bp in stream_sources: module_classes[bp.module.__name__] = bp.module for conn in bp.streams: # Apply remapping remapped_name = blueprint_set.remapping_map.get((bp.module, conn.name), conn.name) + if not isinstance(remapped_name, str): + continue key = (remapped_name, conn.type) if conn.direction == "out": producers[key].append(bp.module) # type: ignore[index] diff --git a/dimos/core/module.py b/dimos/core/module.py index 26a2b6f893..6b42ea6af7 100644 --- a/dimos/core/module.py +++ b/dimos/core/module.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import asyncio -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Mapping from dataclasses import dataclass from functools import partial import inspect @@ -114,6 +114,69 @@ class ModuleConfig(BaseConfig): ModuleConfigT = TypeVar("ModuleConfigT", bound=ModuleConfig, default=ModuleConfig) +StreamDirection = Literal["in", "out"] + + +@dataclass(frozen=True) +class StreamDecl: + name: str + direction: StreamDirection + type: type + + +@dataclass(frozen=True) +class ModuleIOContract: + streams: tuple[StreamDecl, ...] = () + + def __post_init__(self) -> None: + _validate_unique_stream_names(self.streams) + + @classmethod + def from_annotations(cls, module: type["ModuleBase"]) -> "ModuleIOContract": + return cls(streams=_stream_decls_from_annotations(module)) + + +def _validate_unique_stream_names(streams: tuple[StreamDecl, ...]) -> None: + seen: set[str] = set() + duplicates: set[str] = set() + for stream in streams: + if stream.name in seen: + duplicates.add(stream.name) + seen.add(stream.name) + if duplicates: + names = ", ".join(sorted(duplicates)) + raise ValueError(f"Module IO contract has duplicate stream names: {names}") + + +def resolve_module_type_hints(module: type["ModuleBase"]) -> dict[str, Any]: + # Resolve annotations using namespaces from the full MRO chain so that + # In/Out behind TYPE_CHECKING + `from __future__ import annotations` work. + # Iterate reversed MRO so the most specific class's namespace wins when + # parent modules shadow names. + globalns: dict[str, Any] = {} + for c in reversed(module.__mro__): + if c.__module__ in sys.modules: + globalns.update(sys.modules[c.__module__].__dict__) + try: + return get_type_hints(module, globalns=globalns, include_extras=True) + except Exception: + all_annotations: dict[str, Any] = {} + for base_class in reversed(module.__mro__): + if hasattr(base_class, "__annotations__"): + all_annotations.update(base_class.__annotations__) + return all_annotations + + +def _stream_decls_from_annotations(module: type["ModuleBase"]) -> tuple[StreamDecl, ...]: + streams: list[StreamDecl] = [] + for name, annotation in resolve_module_type_hints(module).items(): + origin = get_origin(annotation) + if origin in (In, Out): + direction: StreamDirection = "in" if origin == In else "out" + type_ = get_args(annotation)[0] + streams.append(StreamDecl(name=name, type=type_, direction=direction)) + return tuple(streams) + class _BlueprintPartial(Protocol): def __call__(self, **kwargs: Any) -> "Blueprint": ... @@ -143,8 +206,25 @@ class ModuleBase(Configurable, CompositeResource): _tools: dict[str, Any] _tools_lock: threading.Lock - def __init__(self, config_args: dict[str, Any]) -> None: - super().__init__(**config_args) + @classmethod + def resolve_config(cls, config_args: Mapping[str, Any]) -> ModuleConfig: + config_type = resolve_module_type_hints(cls)["config"] + return config_type(**dict(config_args)) + + @classmethod + def io_contract(cls, config: ModuleConfig) -> ModuleIOContract: + del config + return ModuleIOContract.from_annotations(cls) + + def __init__( + self, + config_args: dict[str, Any], + resolved_config: ModuleConfig | None = None, + ) -> None: + if resolved_config is None: + super().__init__(**config_args) + else: + self.config = resolved_config self._module_closed_lock = threading.Lock() self._tools = {} self._tools_lock = threading.Lock() @@ -285,19 +365,11 @@ def tf(self, value) -> None: # type: ignore[no-untyped-def] @property def outputs(self) -> dict[str, Out]: # type: ignore[type-arg] - return { - name: s - for name, s in self.__dict__.items() - if isinstance(s, Out) and not name.startswith("_") - } + return dict(getattr(self, "_outputs", {})) @property def inputs(self) -> dict[str, In]: # type: ignore[type-arg] - return { - name: s - for name, s in self.__dict__.items() - if isinstance(s, In) and not name.startswith("_") - } + return dict(getattr(self, "_inputs", {})) @classproperty def rpcs(self) -> dict[str, Callable[..., Any]]: @@ -738,44 +810,48 @@ def __init_subclass__(cls, **kwargs: Any) -> None: """ super().__init_subclass__(**kwargs) - try: - hints = get_type_hints(cls, include_extras=True) - except (NameError, AttributeError, TypeError): - hints = {} - - for name, ann in hints.items(): - origin = get_origin(ann) - if origin in (In, Out): - # Set class-level attribute if not already set. - if not hasattr(cls, name) or getattr(cls, name) is None: - setattr(cls, name, None) + for stream in _stream_decls_from_annotations(cls): + # Set class-level attribute if not already set. + if not hasattr(cls, stream.name) or getattr(cls, stream.name) is None: + setattr(cls, stream.name, None) def __init__(self, **kwargs: Any) -> None: self.ref = None + self._inputs: dict[str, In[Any]] = {} + self._outputs: dict[str, Out[Any]] = {} + config = type(self).resolve_config(kwargs) + io_contract = type(self).io_contract(config) + default_contract = _uses_default_io_contract(type(self)) + annotated_stream_keys = { + (decl.name, decl.direction) for decl in _stream_decls_from_annotations(type(self)) + } - try: - hints = get_type_hints(self.__class__, include_extras=True) - except (NameError, AttributeError, TypeError): - hints = {} - - for name, ann in hints.items(): - origin = get_origin(ann) - if origin is Out: - inner, *_ = get_args(ann) or (Any,) - stream = Out(inner, name, self) # type: ignore[var-annotated] - setattr(self, name, stream) - elif origin is In: - inner, *_ = get_args(ann) or (Any,) - stream = In(inner, name, self) # type: ignore[assignment] - setattr(self, name, stream) - super().__init__(config_args=kwargs) + for decl in io_contract.streams: + install_attribute = ( + default_contract or (decl.name, decl.direction) in annotated_stream_keys + ) + self._register_stream_decl(decl, install_attribute=install_attribute) + super().__init__(config_args=kwargs, resolved_config=config) + + def _register_stream_decl(self, decl: StreamDecl, *, install_attribute: bool) -> None: + if decl.direction == "out": + out_stream: Out[Any] = Out(decl.type, decl.name, self) + self._outputs[decl.name] = out_stream + if install_attribute: + setattr(self, decl.name, out_stream) + return + + in_stream: In[Any] = In(decl.type, decl.name, self) + self._inputs[decl.name] = in_stream + if install_attribute: + setattr(self, decl.name, in_stream) def __str__(self) -> str: return f"{self.__class__.__name__}" @rpc def set_transport(self, stream_name: str, transport: Transport) -> bool: # type: ignore[type-arg] - stream = getattr(self, stream_name, None) + stream = self.outputs.get(stream_name) or self.inputs.get(stream_name) if not stream: raise ValueError(f"{stream_name} not found in {self.__class__.__name__}") @@ -802,7 +878,7 @@ def peek_stream(self, stream_name: str, timeout: float) -> Any: # called from remote def connect_stream(self, input_name: str, remote_stream: RemoteOut[T]): # type: ignore[no-untyped-def] - input_stream = getattr(self, input_name, None) + input_stream = self.inputs.get(input_name) if not input_stream: raise ValueError(f"{input_name} not found in {self.__class__.__name__}") if not isinstance(input_stream, In): @@ -813,6 +889,20 @@ def connect_stream(self, input_name: str, remote_stream: RemoteOut[T]): # type: ModuleSpec = tuple[type[ModuleBase], GlobalConfig, dict[str, Any]] +def _uses_default_io_contract(module: type[ModuleBase]) -> bool: + for cls in module.__mro__: + if "io_contract" not in cls.__dict__: + continue + io_contract = cls.__dict__["io_contract"] + default_io_contract = ModuleBase.__dict__["io_contract"] + return ( + isinstance(io_contract, classmethod) + and isinstance(default_io_contract, classmethod) + and io_contract.__func__ is default_io_contract.__func__ + ) + return False + + def is_module_type(value: Any) -> bool: try: return inspect.isclass(value) and issubclass(value, Module) diff --git a/dimos/core/native_module.py b/dimos/core/native_module.py index 6e34a2ffd5..ae1a6291de 100644 --- a/dimos/core/native_module.py +++ b/dimos/core/native_module.py @@ -457,10 +457,7 @@ def _maybe_build(self) -> None: def _collect_topics(self) -> dict[str, str]: topics: dict[str, str] = {} - for name in list(self.inputs) + list(self.outputs): - stream = getattr(self, name, None) - if stream is None: - continue + for name, stream in {**self.inputs, **self.outputs}.items(): transport = getattr(stream, "_transport", None) if transport is None: continue diff --git a/examples/configuration_resolved_io.py b/examples/configuration_resolved_io.py new file mode 100644 index 0000000000..cd84dcc2f9 --- /dev/null +++ b/examples/configuration_resolved_io.py @@ -0,0 +1,128 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Configuration-resolved module IO demo. + +Run: + + uv run python examples/configuration_resolved_io.py + +This demo uses one reusable module implementation whose final config selects the +stream direction/type pairing: + +- ``text_to_number`` exposes ``text: In[str]`` and ``number: Out[int]`` +- ``number_to_text`` exposes ``number: In[int]`` and ``text: Out[str]`` + +Two configured instances connect into a small loop. The script lets the +blueprint run briefly, then shuts the whole blueprint down with +``coordinator.stop()``. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from contextlib import suppress +import time +from typing import Literal + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.core.module import Module, ModuleConfig, ModuleIOContract, StreamDecl +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +Mode = Literal["text_to_number", "number_to_text"] + + +class AlternatingIOConfig(ModuleConfig): + mode: Mode + interval_s: float = 0.25 + + +class AlternatingIO(Module): + """Module whose input/output stream types are selected by config.""" + + config: AlternatingIOConfig # type: ignore[assignment] + + @classmethod + def io_contract(cls, config: AlternatingIOConfig) -> ModuleIOContract: + if config.mode == "text_to_number": + return ModuleIOContract( + streams=( + StreamDecl(name="text", direction="in", type=str), + StreamDecl(name="number", direction="out", type=int), + ) + ) + + return ModuleIOContract( + streams=( + StreamDecl(name="number", direction="in", type=int), + StreamDecl(name="text", direction="out", type=str), + ) + ) + + async def main(self) -> AsyncIterator[None]: + """Start a background publisher without overriding start()/stop().""" + publisher = asyncio.create_task(self._publish_loop()) + try: + yield + finally: + publisher.cancel() + with suppress(asyncio.CancelledError): + await publisher + + async def _publish_loop(self) -> None: + counter = 0 + while True: + counter += 1 + outputs = self.outputs + if "text" in outputs: + outputs["text"].publish(f"tick-{counter}") + if "number" in outputs: + outputs["number"].publish(counter) + await asyncio.sleep(self.config.interval_s) + + async def handle_text(self, message: str) -> None: + logger.info("Received text", module=type(self).__name__, message=message) + + async def handle_number(self, message: int) -> None: + logger.info("Received number", module=type(self).__name__, message=message) + + +class TextToNumber(AlternatingIO): + pass + + +class NumberToText(AlternatingIO): + pass + + +def main() -> None: + blueprint = autoconnect( + TextToNumber.blueprint(mode="text_to_number"), + NumberToText.blueprint(mode="number_to_text"), + ) + + coordinator = ModuleCoordinator.build(blueprint, {"g": {"viewer": "none"}}) + try: + print("Blueprint running for 2 seconds...") + time.sleep(2.0) + finally: + print("Stopping blueprint...") + coordinator.stop() + + +if __name__ == "__main__": + main() diff --git a/openspec/changes/configuration-resolved-module-io/.openspec.yaml b/openspec/changes/archive/2026-06-30-configuration-resolved-module-io/.openspec.yaml similarity index 100% rename from openspec/changes/configuration-resolved-module-io/.openspec.yaml rename to openspec/changes/archive/2026-06-30-configuration-resolved-module-io/.openspec.yaml diff --git a/openspec/changes/configuration-resolved-module-io/design.md b/openspec/changes/archive/2026-06-30-configuration-resolved-module-io/design.md similarity index 100% rename from openspec/changes/configuration-resolved-module-io/design.md rename to openspec/changes/archive/2026-06-30-configuration-resolved-module-io/design.md diff --git a/openspec/changes/configuration-resolved-module-io/proposal.md b/openspec/changes/archive/2026-06-30-configuration-resolved-module-io/proposal.md similarity index 100% rename from openspec/changes/configuration-resolved-module-io/proposal.md rename to openspec/changes/archive/2026-06-30-configuration-resolved-module-io/proposal.md diff --git a/openspec/changes/configuration-resolved-module-io/specs/configuration-resolved-module-io/spec.md b/openspec/changes/archive/2026-06-30-configuration-resolved-module-io/specs/configuration-resolved-module-io/spec.md similarity index 100% rename from openspec/changes/configuration-resolved-module-io/specs/configuration-resolved-module-io/spec.md rename to openspec/changes/archive/2026-06-30-configuration-resolved-module-io/specs/configuration-resolved-module-io/spec.md diff --git a/openspec/changes/archive/2026-06-30-configuration-resolved-module-io/tasks.md b/openspec/changes/archive/2026-06-30-configuration-resolved-module-io/tasks.md new file mode 100644 index 0000000000..3ed652c0b3 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-configuration-resolved-module-io/tasks.md @@ -0,0 +1,45 @@ +## 1. Core IO Contract API + +- [x] 1.1 Add public `StreamDecl` and `ModuleIOContract` types with stream-name uniqueness validation. +- [x] 1.2 Add shared validation helpers so construction-time and resolution-time checks use the same rules. +- [x] 1.3 Add default `Module.io_contract(config)` that returns annotation-derived IO. +- [x] 1.4 Add tests proving annotation-only modules resolve the same streams as before. + +## 2. Module Stream Registries + +- [x] 2.1 Add explicit internal input/output registries to `Module`. +- [x] 2.2 Register annotated streams in the registries while preserving existing annotated attributes. +- [x] 2.3 Instantiate custom configuration-resolved streams into registries without creating arbitrary attributes. +- [x] 2.4 Update `inputs`, `outputs`, `set_transport()`, `connect_stream()`, and related stream lookup paths to use registries. +- [x] 2.5 Add tests proving configuration-resolved streams are available through `self.inputs` and `self.outputs`. + +## 3. Resolved Blueprint Planning + +- [x] 3.1 Add an internal resolved module plan that stores final module config, final kwargs, and resolved IO contract. +- [x] 3.2 Resolve final per-module config before stream conflict checks, deployment, and wiring in `build()`. +- [x] 3.3 Resolve final per-module config before stream conflict checks, deployment, and wiring in blueprint load paths. +- [x] 3.4 Pass final per-module kwargs into worker deployment instead of late-merging `blueprint_args` inside the worker manager. +- [x] 3.5 Add tests proving CLI/build overrides affect resolved IO before wiring. + +## 4. Remapping and Conflict Validation + +- [x] 4.1 Validate remappings against resolved stream names. +- [x] 4.2 Fail blueprint resolution when a remapping references a stream absent from the resolved IO contract. +- [x] 4.3 Preserve existing same-name/same-type autoconnect behavior after remapping. +- [x] 4.4 Preserve existing same-name/different-type conflict behavior after remapping. +- [x] 4.5 Add tests for valid remapping, stale remapping, autoconnect, and type-conflict scenarios. + +## 5. Teaching Demo + +- [x] 5.1 Add `examples/configuration_resolved_io.py` demonstrating a module whose config selects between two different IO shapes. +- [x] 5.2 Document how to run the demo in the example file or nearby README text. +- [x] 5.3 Add a verification command for the demo and ensure it passes locally. + +## 6. Verification Gates + +- [x] 6.1 Run the focused configuration-resolved IO unit tests. +- [x] 6.2 Run the existing affected core blueprint/module tests. +- [x] 6.3 Run the teaching demo successfully. +- [x] 6.4 Run the standard project test command if practical for the implementation scope. + +Standard test note: `uv run pytest -q` was attempted and timed out after 300s; early failures were unrelated existing codebase checks for `__all__` and `__init__.py` files under hardware/simulation paths. diff --git a/openspec/changes/configuration-resolved-module-io/tasks.md b/openspec/changes/configuration-resolved-module-io/tasks.md deleted file mode 100644 index 95fe456931..0000000000 --- a/openspec/changes/configuration-resolved-module-io/tasks.md +++ /dev/null @@ -1,43 +0,0 @@ -## 1. Core IO Contract API - -- [ ] 1.1 Add public `StreamDecl` and `ModuleIOContract` types with stream-name uniqueness validation. -- [ ] 1.2 Add shared validation helpers so construction-time and resolution-time checks use the same rules. -- [ ] 1.3 Add default `Module.io_contract(config)` that returns annotation-derived IO. -- [ ] 1.4 Add tests proving annotation-only modules resolve the same streams as before. - -## 2. Module Stream Registries - -- [ ] 2.1 Add explicit internal input/output registries to `Module`. -- [ ] 2.2 Register annotated streams in the registries while preserving existing annotated attributes. -- [ ] 2.3 Instantiate custom configuration-resolved streams into registries without creating arbitrary attributes. -- [ ] 2.4 Update `inputs`, `outputs`, `set_transport()`, `connect_stream()`, and related stream lookup paths to use registries. -- [ ] 2.5 Add tests proving configuration-resolved streams are available through `self.inputs` and `self.outputs`. - -## 3. Resolved Blueprint Planning - -- [ ] 3.1 Add an internal resolved module plan that stores final module config, final kwargs, and resolved IO contract. -- [ ] 3.2 Resolve final per-module config before stream conflict checks, deployment, and wiring in `build()`. -- [ ] 3.3 Resolve final per-module config before stream conflict checks, deployment, and wiring in blueprint load paths. -- [ ] 3.4 Pass final per-module kwargs into worker deployment instead of late-merging `blueprint_args` inside the worker manager. -- [ ] 3.5 Add tests proving CLI/build overrides affect resolved IO before wiring. - -## 4. Remapping and Conflict Validation - -- [ ] 4.1 Validate remappings against resolved stream names. -- [ ] 4.2 Fail blueprint resolution when a remapping references a stream absent from the resolved IO contract. -- [ ] 4.3 Preserve existing same-name/same-type autoconnect behavior after remapping. -- [ ] 4.4 Preserve existing same-name/different-type conflict behavior after remapping. -- [ ] 4.5 Add tests for valid remapping, stale remapping, autoconnect, and type-conflict scenarios. - -## 5. Teaching Demo - -- [ ] 5.1 Add `examples/configuration_resolved_io.py` demonstrating a module whose config selects between two different IO shapes. -- [ ] 5.2 Document how to run the demo in the example file or nearby README text. -- [ ] 5.3 Add a verification command for the demo and ensure it passes locally. - -## 6. Verification Gates - -- [ ] 6.1 Run the focused configuration-resolved IO unit tests. -- [ ] 6.2 Run the existing affected core blueprint/module tests. -- [ ] 6.3 Run the teaching demo successfully. -- [ ] 6.4 Run the standard project test command if practical for the implementation scope. diff --git a/openspec/specs/configuration-resolved-module-io/spec.md b/openspec/specs/configuration-resolved-module-io/spec.md new file mode 100644 index 0000000000..826756db9b --- /dev/null +++ b/openspec/specs/configuration-resolved-module-io/spec.md @@ -0,0 +1,69 @@ +# configuration-resolved-module-io Specification + +## Purpose +TBD - created by archiving change configuration-resolved-module-io. Update Purpose after archive. +## Requirements +### Requirement: Default annotation IO remains supported +The system SHALL preserve existing annotation-derived module IO behavior for modules that do not override `Module.io_contract(config)`. + +#### Scenario: Static annotated module resolves IO +- **WHEN** a module declares `In` or `Out` streams with class annotations and does not override `io_contract` +- **THEN** the resolved module IO contract contains the same streams that current annotation discovery would expose + +### Requirement: Modules can return configuration-resolved IO contracts +The system SHALL allow a module to override `Module.io_contract(config)` and return a complete `ModuleIOContract` derived from final validated module config. + +#### Scenario: Config changes IO shape +- **WHEN** a module's final validated config selects one IO mode instead of another +- **THEN** blueprint wiring uses the streams returned by `io_contract(config)` for that selected mode + +#### Scenario: Custom contract replaces annotations +- **WHEN** a module overrides `io_contract(config)` and also has annotated `In` or `Out` fields +- **THEN** blueprint wiring uses only the streams returned by the override + +### Requirement: IO resolves after final config merge +The system SHALL resolve module IO after blueprint kwargs, CLI/config-file module overrides, and global config have been merged into validated module config. + +#### Scenario: CLI override changes resolved streams +- **WHEN** a module's blueprint kwargs select one IO mode and a build/load override selects another IO mode +- **THEN** the resolved IO contract reflects the build/load override + +### Requirement: Dynamic streams use input and output registries +The system SHALL expose resolved streams through `self.inputs` and `self.outputs` for both annotated and configuration-resolved IO. + +#### Scenario: Configuration-resolved input is accessible +- **WHEN** a module resolves an input stream from `io_contract(config)` without an annotated attribute +- **THEN** module code can access that stream through `self.inputs[stream_name]` + +#### Scenario: Annotated stream attributes remain compatible +- **WHEN** a module declares annotated stream attributes +- **THEN** those attributes remain available while the streams are also present in `self.inputs` or `self.outputs` + +### Requirement: Stream names are unique within each module IO contract +The system SHALL reject a `ModuleIOContract` that contains duplicate stream names, even when duplicate names have different directions. + +#### Scenario: Duplicate input and output names are rejected +- **WHEN** a module IO contract declares an input named `joint_state` and an output named `joint_state` +- **THEN** validation fails before blueprint wiring + +### Requirement: Remappings validate against resolved streams +The system SHALL apply stream remappings to resolved stream names and reject remappings whose local stream name is absent from the resolved module IO contract. + +#### Scenario: Valid remapping keeps module-local key +- **WHEN** a resolved input named `primary_camera` is remapped to graph name `zed_front_rgb` +- **THEN** the graph connects using `zed_front_rgb` and module code still accesses `self.inputs["primary_camera"]` + +#### Scenario: Stale remapping fails +- **WHEN** a blueprint remapping references a stream name not present in the resolved IO contract +- **THEN** blueprint resolution fails before deployment + +### Requirement: Existing graph conflict semantics remain unchanged +The system SHALL preserve current graph conflict and autoconnect semantics after applying remappings to resolved stream names. + +#### Scenario: Same graph name and same type autoconnect +- **WHEN** multiple resolved streams share the same graph name and message type after remapping +- **THEN** the coordinator uses the existing shared-transport autoconnect behavior + +#### Scenario: Same graph name and different type fails +- **WHEN** multiple resolved streams share the same graph name with different message types after remapping +- **THEN** the coordinator rejects the blueprint using the existing conflict behavior From 8ec6e20e24001d38f9bfa1c928d68856a1995c28 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 29 Jun 2026 22:29:07 -0700 Subject: [PATCH 104/110] fix: keep coordinator config out of restart blueprint kwargs --- dimos/core/coordination/module_coordinator.py | 3 ++- dimos/core/coordination/test_module_coordinator.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dimos/core/coordination/module_coordinator.py b/dimos/core/coordination/module_coordinator.py index 0302414f82..c516b668b1 100644 --- a/dimos/core/coordination/module_coordinator.py +++ b/dimos/core/coordination/module_coordinator.py @@ -531,7 +531,8 @@ def _restart_module( new_proxy = python_wm.deploy_fresh(new_class, self._global_config, kwargs) self._deployed_modules[new_class] = new_proxy - new_bp = new_class.blueprint(**kwargs) + blueprint_kwargs = {k: v for k, v in kwargs.items() if k != "g"} + new_bp = new_class.blueprint(**blueprint_kwargs) new_atom = new_bp.active_blueprints[0] self._deployed_atoms[new_class] = new_atom config = new_class.resolve_config(kwargs) diff --git a/dimos/core/coordination/test_module_coordinator.py b/dimos/core/coordination/test_module_coordinator.py index 9b89dfca1f..7785fe3c96 100644 --- a/dimos/core/coordination/test_module_coordinator.py +++ b/dimos/core/coordination/test_module_coordinator.py @@ -657,6 +657,8 @@ def test_restart_module_basic(dynamic_coordinator) -> None: assert new_proxy is not old_proxy assert dynamic_coordinator.get_instance(ModuleA) is new_proxy assert new_proxy.get_name() == "A, Module A" + assert "g" in dynamic_coordinator._resolved_module_plans[ModuleA].final_kwargs + assert "g" not in dynamic_coordinator._deployed_atoms[ModuleA].kwargs def test_restart_module_preserves_stream_wiring(dynamic_coordinator) -> None: From 27d54ac7fdc05caffe56a825b03dc2d8b6fa4c3e Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 30 Jun 2026 11:19:55 -0700 Subject: [PATCH 105/110] spec: agentic grasp --- .../manual-agentic-grasp-demo/.openspec.yaml | 2 + .../manual-agentic-grasp-demo/design.md | 90 +++++++++++++++++++ .../manual-agentic-grasp-demo/proposal.md | 45 ++++++++++ .../specs/gpd-grasp-detection/spec.md | 16 ++++ .../manipulation-agentic-primitives/spec.md | 54 +++++++++++ .../specs/manual-agentic-grasp-demo/spec.md | 37 ++++++++ .../manual-agentic-grasp-demo/tasks.md | 34 +++++++ 7 files changed, 278 insertions(+) create mode 100644 openspec/changes/manual-agentic-grasp-demo/.openspec.yaml create mode 100644 openspec/changes/manual-agentic-grasp-demo/design.md create mode 100644 openspec/changes/manual-agentic-grasp-demo/proposal.md create mode 100644 openspec/changes/manual-agentic-grasp-demo/specs/gpd-grasp-detection/spec.md create mode 100644 openspec/changes/manual-agentic-grasp-demo/specs/manipulation-agentic-primitives/spec.md create mode 100644 openspec/changes/manual-agentic-grasp-demo/specs/manual-agentic-grasp-demo/spec.md create mode 100644 openspec/changes/manual-agentic-grasp-demo/tasks.md diff --git a/openspec/changes/manual-agentic-grasp-demo/.openspec.yaml b/openspec/changes/manual-agentic-grasp-demo/.openspec.yaml new file mode 100644 index 0000000000..d6b53dee55 --- /dev/null +++ b/openspec/changes/manual-agentic-grasp-demo/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/manual-agentic-grasp-demo/design.md b/openspec/changes/manual-agentic-grasp-demo/design.md new file mode 100644 index 0000000000..1f2e9bc9f9 --- /dev/null +++ b/openspec/changes/manual-agentic-grasp-demo/design.md @@ -0,0 +1,90 @@ +## Context + +DimOS already has three relevant layers: + +- `AgenticManipulationModule` exposes a small universal agent-facing manipulation facade for robot state, joint motion, speed, and gripper primitives. +- `GPDGraspGenModule` consumes registered-object pointclouds through `GraspGenSpec` and produces `PoseArray` grasp candidates for the current GPD MuJoCo demo. +- The existing `gpd-mujoco-grasp-demo` intentionally proves perception-to-GPD candidate generation only; it does not command robot motion or gripper execution. + +The new round should prove a stronger but still deterministic claim: manual agent-facing tool calls can compose perception, GPD grasp generation, motion planning, and gripper execution in MuJoCo. It should not depend on an autonomous LLM agent, physical hardware, or contact-stable object-lift assertions. + +## Goals / Non-Goals + +**Goals:** + +- Keep a single manipulation-facing tool namespace by adding the grasp-capable facade in `dimos/manipulation/agentic_manipulation_module.py`. +- Preserve the universal `AgenticManipulationModule` for blueprints that only provide basic motion/gripper dependencies. +- Add a separate `AgenticGraspManipulationModule` class for blueprints that also provide perception and grasp-generation dependencies. +- Expose Round 1 manual skills for scanning, grasp generation, cached candidate execution, pose motion, world-frame relative motion, home motion, and gripper control. +- Make `execute_grasp(candidate_index=0)` execution-only over cached candidates created by a previous `generate_grasps(...)` call. +- Add a deterministic MuJoCo manual demo path and gated smoke test that require pipeline completion, not object-lift success. +- Document the manual MCP/tool-call sequence and visual Rerun checklist. + +**Non-Goals:** + +- Autonomous LLM/McpClient execution. +- Physical robot grasp CI. +- Required object-lift/contact success assertion. +- Collision-aware GPD filtering. +- Multi-candidate retry policy. +- `grab_object(...)` macro. +- Place/drop/pick-and-place workflows. +- Raw plan execution APIs or arbitrary trajectory/code-as-policy interfaces. + +## Decisions + +### Keep the agent-facing surface in one file, split by dependency level + +Add `AgenticGraspManipulationModule` in `agentic_manipulation_module.py` rather than creating a separate top-level demo controller module. This keeps the public agent-facing manipulation surface discoverable in one place while avoiding mandatory perception/GPD dependencies for the existing universal facade. + +Alternatives considered: + +- Extend `GraspingModule`: rejected because it would mix grasp generation with robot execution and agent-facing motion primitives. +- Extend only `PickAndPlaceModule`: rejected because this round is specifically about executing cached GPD candidates, not the existing heuristic pick pipeline. +- Put all dependencies directly on `AgenticManipulationModule`: rejected because blueprints with only basic motion/gripper providers would fail dependency injection. + +### Treat `execute_grasp(...)` as execution-only over cached candidates + +`generate_grasps(...)` owns grasp target resolution and candidate generation. `execute_grasp(candidate_index=0)` selects from the latest cached candidates and runs a conservative sequence: open gripper, move to pregrasp, move to grasp pose, close gripper, lift/retract. + +This explicit sequence makes the manual demo debuggable and avoids hiding scan/regenerate behavior inside execution. + +### Default relative motion to world frame + +Round 1 relative motion uses `frame="world"` by default for `move_relative(...)` and `move_along_axis(...)`. This makes `dz > 0` a stable lift command in the sim demo and matches the current RoboPlan relative Cartesian support boundary. + +EE-frame relative motion can be added later after axis conventions are documented and tested. + +### Use tiered demo success criteria + +The required smoke test verifies the pipeline completes: registered grasp target found, non-empty candidates generated, and `execute_grasp(0)` completes motion/gripper commands. Object visibly lifting in MuJoCo/Rerun is a demo-quality checklist item, not a CI gate. + +This avoids making contact physics stability the blocker for validating module wiring and tool contracts. + +### Keep the existing candidate-generation-only GPD demo intact + +The current `gpd-mujoco-grasp-demo` remains valid and non-executing. The manual execution demo should be separate or opt-in so the previous documentation and smoke behavior stay honest. + +## Risks / Trade-offs + +- **Grasp pose convention mismatch** → Start with conservative pregrasp/lift distances, document the expected pose convention, and keep object-lift as visual quality rather than required CI success. +- **Dependency injection becomes brittle** → Keep the universal and grasp-capable facades as separate classes so basic blueprints do not require perception or GPD providers. +- **GPD may return unstable or empty candidates** → Smoke tests should fail clearly on empty candidates, while docs should describe runtime preparation and visual diagnostics. +- **Manual tool transport could be confused with autonomous agent success** → Name and document the demo as manual MCP/tool-call driven and explicitly out of scope autonomous LLM execution. +- **Execution sequence could duplicate future pick/place logic** → Keep Round 1 narrow; defer `grab_object(...)`, retries, and place/pick-and-place until candidate execution proves stable. + +## Migration Plan + +1. Add facade/spec contracts and unit tests for the new agent-facing skills without simulator dependencies. +2. Wire a MuJoCo/GPD manual demo blueprint or opt-in path that includes the grasp-capable facade and required perception/grasp/motion providers. +3. Add the gated self-hosted/MuJoCo smoke test and require only pipeline completion. +4. Update docs with runtime preparation, manual command sequence, and Rerun visual checklist. +5. Keep existing GPD candidate-generation demo available for users who only want detection visualization. + +Rollback is straightforward: remove the new grasp-capable facade wiring, smoke test, and docs while leaving the existing universal facade and GPD detector unchanged. + +## Open Questions + +- Exact blueprint name and command shape for the manual execution demo. +- Whether the manual driver should call MCP CLI commands only, or use a small Python helper that performs equivalent tool calls against the running blueprint. +- Exact pregrasp/retract offsets and candidate orientation convention needed for stable visual demo behavior. diff --git a/openspec/changes/manual-agentic-grasp-demo/proposal.md b/openspec/changes/manual-agentic-grasp-demo/proposal.md new file mode 100644 index 0000000000..7372ef68cc --- /dev/null +++ b/openspec/changes/manual-agentic-grasp-demo/proposal.md @@ -0,0 +1,45 @@ +## Why + +The existing GPD MuJoCo demo proves pointcloud-to-grasp-candidate generation, but it intentionally stops before robot motion and gripper execution. We need a deterministic, manual, agent-facing demo that proves the same tool surface an agent would use can compose registered object detection, GPD grasp generation, motion planning, and gripper execution in simulation without depending on an autonomous LLM loop. + +## What Changes + +- Add a grasp-capable agentic manipulation facade in the existing agent-facing manipulation module file, separate from the universal motion/gripper facade. +- Expose a small Round 1 skill surface for manual agent-facing grasp demos: + - `scan_objects(...)` + - `generate_grasps(...)` + - `execute_grasp(candidate_index=0)` + - `move_to_pose(...)` + - `move_relative(dx, dy, dz, frame="world")` + - `move_along_axis(axis, distance, frame="world")` + - `go_home()` + - `open_gripper()` + - `close_gripper()` + - `set_gripper(...)` +- Make `execute_grasp(candidate_index=0)` execution-only over cached grasp candidates produced by a prior `generate_grasps(...)` call. +- Add a manual MuJoCo demo path that runs a deterministic sequence through the agent-facing tools: `scan_objects("sphere") -> generate_grasps("sphere") -> execute_grasp(0)`. +- Add a gated MuJoCo/self-hosted smoke test that verifies pipeline completion without requiring object-lift success as a pass/fail assertion. +- Document the manual command sequence and Rerun visual checklist for demo-quality validation. +- Keep autonomous LLM/McpClient execution, physical robot grasp CI, object-lift pass/fail assertions, collision-aware GPD filtering, retry policy, `grab_object(...)`, place/drop/pick-and-place, and raw plan execution APIs out of scope for this round. + +## Capabilities + +### New Capabilities +- `manual-agentic-grasp-demo`: Defines the deterministic manual sim demo, smoke-test success criteria, and documentation/checklist for composing agent-facing grasp tools end-to-end. + +### Modified Capabilities +- `manipulation-agentic-primitives`: Extends the agent-facing manipulation primitive surface with a grasp-capable facade class, pose motion, world-frame relative motion, named home motion, and set-gripper support. +- `gpd-grasp-detection`: Extends the GPD demo workflow from candidate-generation-only visualization to an optional manual execution demo that consumes cached candidates through the agent-facing facade, while preserving the existing non-execution demo behavior. + +## Impact + +- Affected modules/specs: + - `dimos/manipulation/agentic_manipulation_module.py` + - `dimos/manipulation/agentic_manipulation_spec.py` + - grasping/perception specs used by the grasp-capable facade + - xArm MuJoCo/GPD blueprint wiring +- Affected validation/docs: + - agentic manipulation unit tests + - gated MuJoCo/self-hosted smoke tests + - GPD MuJoCo demo documentation and manual command sequence +- No breaking changes are intended for existing agentic manipulation primitives or the current candidate-generation-only GPD demo. diff --git a/openspec/changes/manual-agentic-grasp-demo/specs/gpd-grasp-detection/spec.md b/openspec/changes/manual-agentic-grasp-demo/specs/gpd-grasp-detection/spec.md new file mode 100644 index 0000000000..605f806f0e --- /dev/null +++ b/openspec/changes/manual-agentic-grasp-demo/specs/gpd-grasp-detection/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: GPD candidates can feed manual grasp execution demo +The GPD grasp detection workflow SHALL support an opt-in manual execution demo that consumes generated Grasp candidates through the agent-facing grasp-capable manipulation facade while preserving the existing candidate-generation-only demo behavior. + +#### Scenario: Existing GPD visualization demo remains non-executing +- **WHEN** the existing candidate-generation-only GPD MuJoCo demo runs +- **THEN** it MUST continue to stop before robot motion, gripper actuation, pick/place motion, or trajectory execution + +#### Scenario: Manual execution demo consumes generated candidates +- **WHEN** the manual execution demo runs and GPD returns one or more Grasp candidates for the configured Grasp target +- **THEN** the agent-facing grasp-capable facade MUST be able to cache those candidates for a subsequent `execute_grasp(candidate_index)` call + +#### Scenario: Empty GPD result is reported clearly +- **WHEN** GPD returns no usable Grasp candidates during the manual execution demo +- **THEN** the agent-facing sequence MUST report a clear empty-result failure before attempting `execute_grasp(...)` diff --git a/openspec/changes/manual-agentic-grasp-demo/specs/manipulation-agentic-primitives/spec.md b/openspec/changes/manual-agentic-grasp-demo/specs/manipulation-agentic-primitives/spec.md new file mode 100644 index 0000000000..fed31ee421 --- /dev/null +++ b/openspec/changes/manual-agentic-grasp-demo/specs/manipulation-agentic-primitives/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Grasp-capable agentic manipulation facade +The system SHALL provide a grasp-capable agent-facing manipulation facade separate from the universal manipulation facade for blueprints that include perception and grasp-generation dependencies. + +#### Scenario: Universal facade remains dependency-light +- **WHEN** a blueprint only provides the universal manipulation provider dependency +- **THEN** `AgenticManipulationModule` MUST remain usable without requiring object-scene registration, GPD, or grasp orchestration providers + +#### Scenario: Grasp facade exposes additional dependencies +- **WHEN** a blueprint wires the grasp-capable facade +- **THEN** the facade MUST be able to delegate object scanning, grasp generation, and motion/gripper execution through injected DimOS Spec/RPC dependencies + +### Requirement: Round 1 grasp demo skill surface +The grasp-capable facade SHALL expose the Round 1 agent-facing skills needed for the manual sim grasp demo and useful motion debugging. + +#### Scenario: Scene and grasp skills are available +- **WHEN** a caller inspects the grasp-capable facade skill schema +- **THEN** `scan_objects(...)`, `generate_grasps(...)`, and `execute_grasp(candidate_index=0)` MUST be available as skills + +#### Scenario: Motion and gripper skills are available +- **WHEN** a caller inspects the grasp-capable facade skill schema +- **THEN** `move_to_pose(...)`, `move_relative(...)`, `move_along_axis(...)`, `go_home()`, `open_gripper()`, `close_gripper()`, and `set_gripper(...)` MUST be available as skills + +### Requirement: Cached Grasp candidate execution +The grasp-capable facade SHALL execute a selected cached Grasp candidate without implicitly rescanning or regenerating grasps. + +#### Scenario: Execute selected cached candidate +- **WHEN** `generate_grasps(...)` has cached one or more Grasp candidates and the caller invokes `execute_grasp(candidate_index)` with a valid index +- **THEN** the facade MUST select that cached candidate and command a conservative execution sequence that opens the gripper, moves to pregrasp, moves to the grasp pose, closes the gripper, and lifts or retracts + +#### Scenario: Execute fails without cached candidates +- **WHEN** a caller invokes `execute_grasp(...)` before any Grasp candidates are cached +- **THEN** the facade MUST return a clear failure explaining that `generate_grasps(...)` must be called first +- **AND** it MUST NOT implicitly call `scan_objects(...)` or `generate_grasps(...)` + +#### Scenario: Execute rejects invalid candidate index +- **WHEN** a caller invokes `execute_grasp(candidate_index)` with an index outside the cached Grasp candidate range +- **THEN** the facade MUST return a clear invalid-input failure without commanding robot motion + +### Requirement: World-frame relative motion skills +The agent-facing relative motion skills SHALL default to world-frame Cartesian deltas for Round 1. + +#### Scenario: Relative motion defaults to world frame +- **WHEN** a caller invokes `move_relative(dx, dy, dz)` without a frame argument +- **THEN** the requested translation MUST be interpreted in the world/base frame + +#### Scenario: Axis motion defaults to world frame +- **WHEN** a caller invokes `move_along_axis(axis, distance)` without a frame argument +- **THEN** the requested axis motion MUST be interpreted in the world/base frame + +#### Scenario: Unsupported relative frame fails clearly +- **WHEN** a caller invokes relative motion with a frame that the underlying planner does not support +- **THEN** the facade MUST return a clear unsupported-frame failure instead of silently changing frames diff --git a/openspec/changes/manual-agentic-grasp-demo/specs/manual-agentic-grasp-demo/spec.md b/openspec/changes/manual-agentic-grasp-demo/specs/manual-agentic-grasp-demo/spec.md new file mode 100644 index 0000000000..5e6b9a63f1 --- /dev/null +++ b/openspec/changes/manual-agentic-grasp-demo/specs/manual-agentic-grasp-demo/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Manual agent-facing grasp pipeline demo +The system SHALL provide a deterministic MuJoCo demo path that proves manual agent-facing tool calls can compose registered-object scanning, GPD grasp generation, and grasp execution without requiring an autonomous LLM loop. + +#### Scenario: Manual sequence completes through tool surface +- **WHEN** the manual grasp demo is running with the configured sphere Grasp target visible +- **THEN** a caller MUST be able to invoke `scan_objects("sphere")`, `generate_grasps("sphere")`, and `execute_grasp(0)` through the agent-facing tool surface +- **AND** the sequence MUST route through the same facade methods intended for future agent/MCP callers + +#### Scenario: Demo does not require autonomous LLM execution +- **WHEN** the required manual grasp demo or smoke test runs +- **THEN** it MUST NOT require an `McpClient`, model API key, or autonomous LLM decision loop + +### Requirement: Gated sim smoke validation +The system SHALL include an opt-in MuJoCo/self-hosted smoke validation for the manual grasp pipeline. + +#### Scenario: Smoke test validates pipeline completion +- **WHEN** the gated smoke test runs in an environment with the required MuJoCo and GPD runtime dependencies prepared +- **THEN** it MUST fail if the configured Grasp target cannot be registered +- **AND** it MUST fail if grasp generation returns no cached Grasp candidates +- **AND** it MUST fail if `execute_grasp(0)` does not complete its motion/gripper sequence successfully + +#### Scenario: Smoke test does not require object lift success +- **WHEN** `execute_grasp(0)` completes in the smoke test +- **THEN** the test MUST NOT require a MuJoCo contact/object-lift assertion as its pass/fail condition + +### Requirement: Manual demo documentation +The system SHALL document how to prepare and run the manual agent-facing grasp demo and how to assess visual demo quality. + +#### Scenario: User follows manual command sequence +- **WHEN** a user follows the manual demo documentation after preparing the required runtime +- **THEN** the documentation MUST provide the command sequence for starting the demo and invoking the manual tool calls + +#### Scenario: User checks Rerun visual outputs +- **WHEN** a user runs the manual demo with visualization enabled +- **THEN** the documentation MUST describe the expected Rerun checklist: registered Grasp target, GPD Grasp candidates, robot approach, gripper close, and lift/retract motion diff --git a/openspec/changes/manual-agentic-grasp-demo/tasks.md b/openspec/changes/manual-agentic-grasp-demo/tasks.md new file mode 100644 index 0000000000..984a1c9ff2 --- /dev/null +++ b/openspec/changes/manual-agentic-grasp-demo/tasks.md @@ -0,0 +1,34 @@ +## 1. Agent-facing facade contracts + +- [ ] 1.1 Extend the agentic manipulation spec/contracts needed for pose motion, world-frame relative motion, home motion, set-gripper, scene scanning, grasp generation, and cached candidate execution. +- [ ] 1.2 Add `AgenticGraspManipulationModule` in `dimos/manipulation/agentic_manipulation_module.py` while preserving the existing dependency-light `AgenticManipulationModule` behavior. +- [ ] 1.3 Implement Round 1 skill metadata/docstrings for `scan_objects`, `generate_grasps`, `execute_grasp`, `move_to_pose`, `move_relative`, `move_along_axis`, `go_home`, `open_gripper`, `close_gripper`, and `set_gripper`. + +## 2. Grasp candidate orchestration + +- [ ] 2.1 Implement `scan_objects(...)` delegation to object-scene/perception registration and return clear registered Grasp target summaries. +- [ ] 2.2 Implement `generate_grasps(...)` delegation to the configured grasp generation path and cache generated Grasp candidates for later execution. +- [ ] 2.3 Implement `execute_grasp(candidate_index=0)` as execution-only over cached candidates, including no-cache and invalid-index failures before motion commands. +- [ ] 2.4 Implement the conservative execution sequence for a selected candidate: open gripper, move to pregrasp, move to grasp pose, close gripper, then lift or retract in world frame. +- [ ] 2.5 Implement `move_relative(...)` and `move_along_axis(...)` with `frame="world"` defaults and clear unsupported-frame failures. + +## 3. Demo wiring + +- [ ] 3.1 Add or update MuJoCo/GPD blueprint wiring for a manual execution demo that includes perception, GPD grasp generation, manipulation execution, and the grasp-capable agentic facade without requiring `McpClient`. +- [ ] 3.2 Preserve the existing candidate-generation-only `gpd-mujoco-grasp-demo` behavior so it remains non-executing. +- [ ] 3.3 Provide a manual driver path using MCP/tool calls or an equivalent deterministic helper sequence for `scan_objects("sphere")`, `generate_grasps("sphere")`, and `execute_grasp(0)`. + +## 4. Tests and validation + +- [ ] 4.1 Add simulator-free unit tests for universal facade preservation and grasp-capable facade skill schema/delegation behavior. +- [ ] 4.2 Add unit tests for cached Grasp candidate execution semantics: success path, missing cache, invalid index, and no implicit regenerate behavior. +- [ ] 4.3 Add unit tests for world-frame relative motion defaults and unsupported-frame error handling. +- [ ] 4.4 Add a gated MuJoCo/self-hosted smoke test that validates registered Grasp target discovery, non-empty cached candidates, and `execute_grasp(0)` pipeline completion without requiring object-lift assertion. +- [ ] 4.5 Run focused default tests for agentic manipulation and grasp facade behavior. + +## 5. Documentation + +- [ ] 5.1 Document runtime preparation and startup commands for the manual agent-facing GPD MuJoCo grasp demo. +- [ ] 5.2 Document the manual command sequence for scanning the sphere, generating grasps, and executing candidate 0. +- [ ] 5.3 Add the Rerun visual checklist: registered Grasp target, GPD Grasp candidates, robot approach, gripper close, and lift/retract motion. +- [ ] 5.4 Clearly label autonomous LLM execution, physical robot grasp CI, object-lift pass/fail checks, collision-aware filtering, retries, `grab_object`, place/drop/pick-and-place, and raw plan execution as out of scope for this round. From 5b0a0fe9a556a2419be7328cb364e96df50480d9 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 30 Jun 2026 15:22:00 -0700 Subject: [PATCH 106/110] feat: migrate sim sidecars to runtime modules --- CONTEXT.md | 24 +- .../runtime/test_fake_runtime_module.py | 219 + .../test_libero_pro_sidecar_profile.py | 70 +- .../runtime/test_robosuite_sidecar_profile.py | 50 +- .../runtime/test_runtime_sidecar_modules.py | 378 ++ .../simulation/runtime_client/http_client.py | 100 - .../runtime_client/test_http_client.py | 35 - dimos/simulation/runtime_module.py | 191 + docs/development/runtime_sidecars.md | 362 +- docs/usage/runtime_environments.md | 39 + .../.openspec.yaml | 2 + .../design.md | 135 + .../proposal.md | 33 + .../specs/runtime-libero-pro-sidecar/spec.md | 57 + .../specs/runtime-protocol/spec.md | 38 + .../specs/runtime-robosuite-sidecar/spec.md | 38 + .../specs/runtime-scripted-demos/spec.md | 58 + .../specs/simulator-runtime-modules/spec.md | 90 + .../tasks.md | 48 + .../specs/runtime-libero-pro-sidecar/spec.md | 64 +- openspec/specs/runtime-protocol/spec.md | 48 +- .../specs/runtime-robosuite-sidecar/spec.md | 54 +- openspec/specs/runtime-scripted-demos/spec.md | 52 +- .../specs/simulator-runtime-modules/spec.md | 94 + .../dimos-fake-runtime-sidecar/pyproject.toml | 8 +- .../dimos_fake_runtime_sidecar/blueprint.py | 46 + .../src/dimos_fake_runtime_sidecar/module.py | 151 + .../src/dimos_fake_runtime_sidecar/server.py | 94 +- .../dimos-libero-pro-sidecar/pyproject.toml | 12 +- .../src/dimos_libero_pro_sidecar/blueprint.py | 74 + .../src/dimos_libero_pro_sidecar/module.py | 232 + .../src/dimos_libero_pro_sidecar/server.py | 132 +- .../dimos-robosuite-sidecar/pyproject.toml | 13 +- .../src/dimos_robosuite_sidecar/blueprint.py | 68 + .../src/dimos_robosuite_sidecar/module.py | 251 + .../src/dimos_robosuite_sidecar/server.py | 193 +- packages/dimos-robosuite-sidecar/uv.lock | 4120 +++++++++++++++++ pyproject.toml | 1 + .../demo_agentic_manipulation_robosuite.py | 920 ---- .../benchmarks/demo_fake_runtime_sidecar.py | 171 +- scripts/benchmarks/demo_libero_pro_runtime.py | 462 +- scripts/benchmarks/demo_rerun_color_smoke.py | 25 +- .../demo_robosuite_camera_payload_smoke.py | 320 -- .../benchmarks/demo_robosuite_panda_lift.py | 547 +-- 44 files changed, 7221 insertions(+), 2898 deletions(-) create mode 100644 dimos/benchmark/runtime/test_fake_runtime_module.py create mode 100644 dimos/benchmark/runtime/test_runtime_sidecar_modules.py delete mode 100644 dimos/simulation/runtime_client/http_client.py delete mode 100644 dimos/simulation/runtime_client/test_http_client.py create mode 100644 dimos/simulation/runtime_module.py create mode 100644 openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/design.md create mode 100644 openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/proposal.md create mode 100644 openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-libero-pro-sidecar/spec.md create mode 100644 openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-protocol/spec.md create mode 100644 openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-robosuite-sidecar/spec.md create mode 100644 openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-scripted-demos/spec.md create mode 100644 openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/simulator-runtime-modules/spec.md create mode 100644 openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/tasks.md create mode 100644 openspec/specs/simulator-runtime-modules/spec.md create mode 100644 packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/blueprint.py create mode 100644 packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/module.py create mode 100644 packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/blueprint.py create mode 100644 packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/module.py create mode 100644 packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/blueprint.py create mode 100644 packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/module.py create mode 100644 packages/dimos-robosuite-sidecar/uv.lock delete mode 100644 scripts/benchmarks/demo_agentic_manipulation_robosuite.py delete mode 100644 scripts/benchmarks/demo_robosuite_camera_payload_smoke.py diff --git a/CONTEXT.md b/CONTEXT.md index dbd77aef49..5b92b293ae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -88,6 +88,18 @@ _Avoid_: autonomous simulator loop, write-triggered stepping, settle step A separate process or environment that owns a benchmark simulator backend while DimOS owns orchestration, control integration, skills, and artifacts. _Avoid_: plugin, embedded simulator +**HTTP runtime removal gate**: +A migration success condition requiring existing HTTP runtime sidecar servers, clients, payload fetch endpoints, and HTTP-first demos to be removed once their behavior is covered by Simulator Runtime Modules. +_Avoid_: HTTP fallback, target simulator architecture, long-term transport boundary + +**Simulator Runtime Module**: +A first-class DimOS Module that represents a simulator runtime at the blueprint boundary while preserving simulator ownership of benchmark reset, stepping, observations, and scoring. +_Avoid_: HTTP runtime sidecar, benchmark script launcher, embedded simulator + +**Simulator runtime blueprint helper**: +A package-local blueprint factory that registers a simulator runtime's named Python project environment and places its Simulator Runtime Module into that environment using the standard blueprint placement API. +_Avoid_: module-local deployment flag, global sidecar registry, caller-written placement boilerplate + **Remote module worker**: A separate Python environment that hosts first-class DimOS Modules while the main DimOS process owns blueprint orchestration and module coordination. _Avoid_: arbitrary sidecar service, embedded optional dependency @@ -145,11 +157,11 @@ The phase-1 rule that an incompatible or failing venv module implementation fail _Avoid_: optional module fallback, background retry policy, partial blueprint success **Venv module placement API**: -A blueprint-level mapping that runs selected Python module contracts in named venvs using concrete module implementation descriptors. +A blueprint-level mapping that runs selected import-safe Python Module classes in named runtime environments. _Avoid_: new Module base class, permanent class deployment attribute, global transport setting **Python venv placement**: -A mapping entry keyed by the coordinator-visible module contract class and valued by a named venv plus implementation descriptor. +A mapping entry keyed by an import-safe Python Module class and valued by the name of a Python runtime environment. _Avoid_: deployment type, stream transport key, hardcoded worker process **Reserved deploy kwargs**: @@ -248,10 +260,18 @@ _Avoid_: task observation, scene observation, evaluator state A hardware control surface that treats a robot as an ordered set of motors with per-motor state and commands, independent of whether the robot is a manipulator, mobile base, or humanoid. _Avoid_: manipulator-only adapter, end-effector API, task action API +**Runtime motor action frame**: +An ordered command frame addressed to a simulator runtime's declared whole-body motor surface for one benchmark step. +_Avoid_: backend-native action vector, opaque simulator action, stream transport payload + **Benchmark episode config**: A backend-facing declaration of benchmark intent that names the task, robot, runtime constraints, and evaluation setup before any DimOS blueprint is launched. _Avoid_: hardware config, simulator config, blueprint config +**Benchmark reset authority**: +The control-plane responsibility for establishing a new benchmark episode state before simulation time advances. +_Avoid_: reset topic ownership, observation stream side effect, simulator auto-reset + **Semantic skill benchmark episode**: A benchmark episode where the agent acts through named, task-level DimOS skills while simulator backends provide reset, observation, and external scoring. _Avoid_: motor-control benchmark episode, raw simulator action episode, code-as-policy episode diff --git a/dimos/benchmark/runtime/test_fake_runtime_module.py b/dimos/benchmark/runtime/test_fake_runtime_module.py new file mode 100644 index 0000000000..178c9a8ce7 --- /dev/null +++ b/dimos/benchmark/runtime/test_fake_runtime_module.py @@ -0,0 +1,219 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the fake simulator runtime DimOS module path.""" + +from __future__ import annotations + +from pathlib import Path +import sys +from unittest.mock import patch + +from dimos.core.module import Module +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment +from dimos.core.stream import Out +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image +from dimos.simulation.runtime_module import SimulatorOwnerThread, publish_output + +REPO_ROOT = Path(__file__).resolve().parents[3] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +FAKE_RUNTIME_SRC = REPO_ROOT / "packages" / "dimos-fake-runtime-sidecar" / "src" +sys.path.insert(0, str(PROTOCOL_SRC)) +sys.path.insert(0, str(FAKE_RUNTIME_SRC)) + +from dimos_fake_runtime_sidecar.blueprint import ( + FAKE_RUNTIME_ENV_NAME, + FAKE_RUNTIME_PROJECT, + fake_runtime_blueprint, +) +from dimos_fake_runtime_sidecar.module import FakeRuntimeModule +from dimos_runtime_protocol.models import ( + CommandMode, + EpisodeResetRequest, + MotorActionFrame, + MotorStateFrame, + ObservationFrame, + StepRequest, +) + + +def test_fake_runtime_blueprint_places_only_module_in_python_project_runtime() -> None: + blueprint = fake_runtime_blueprint(robot_id="testbot", dof=2, step_hz=50) + + assert [atom.module for atom in blueprint.blueprints] == [FakeRuntimeModule] + assert blueprint.blueprints[0].kwargs == {"robot_id": "testbot", "dof": 2, "step_hz": 50} + assert blueprint.runtime_placement_map == {FakeRuntimeModule: FAKE_RUNTIME_ENV_NAME} + + environment = blueprint.runtime_environment_registry.resolve(FAKE_RUNTIME_ENV_NAME) + assert isinstance(environment, PythonProjectRuntimeEnvironment) + assert environment.name == FAKE_RUNTIME_ENV_NAME + assert environment.project == FAKE_RUNTIME_PROJECT + + +def test_fake_runtime_blueprint_accepts_explicit_python_project_runtime() -> None: + runtime = PythonProjectRuntimeEnvironment( + name="custom-fake-runtime", project=FAKE_RUNTIME_PROJECT + ) + + blueprint = fake_runtime_blueprint(runtime=runtime) + + assert blueprint.runtime_environment_registry.resolve(runtime.name) is runtime + assert blueprint.runtime_placement_map == {FakeRuntimeModule: runtime.name} + + +def test_fake_runtime_module_reset_step_score_are_synchronous_and_lightweight() -> None: + module = _make_local_fake_runtime_module(robot_id="testbot", dof=2, step_hz=10) + motor_states: list[MotorStateFrame] = [] + color_images: list[Image] = [] + camera_infos: list[CameraInfo] = [] + runtime_events: list[ObservationFrame] = [] + module.motor_state.subscribe(motor_states.append) + module.color_image.subscribe(color_images.append) + module.camera_info.subscribe(camera_infos.append) + module.runtime_event.subscribe(runtime_events.append) + + try: + description = module.describe() + reset = module.reset(EpisodeResetRequest(episode_id="episode-1", task_id="task-1")) + step = module.step( + StepRequest( + episode_id="episode-1", + tick_id=1, + action=MotorActionFrame( + robot_id="testbot", + mode=CommandMode.POSITION, + names=["testbot/joint1", "testbot/joint2"], + q=[1.0, -1.0], + ), + ) + ) + score = module.score() + + assert description.observation_streams == ["color_image", "camera_info", "runtime_event"] + assert description.metadata["module_streams"]["motor_state"] == "motor_state" + assert reset.episode_id == "episode-1" + assert reset.runtime_description.observation_streams == [ + "color_image", + "camera_info", + "runtime_event", + ] + assert reset.observations == [] + assert step.episode_id == "episode-1" + assert step.tick_id == 1 + assert step.observations == [] + assert step.motor_state.sequence == 1 + assert step.motor_state.q == [0.35, -0.35] + assert score.episode_id == "episode-1" + assert score.success is True + assert score.metrics == {"sequence": 1} + + assert motor_states == [step.motor_state] + assert len(color_images) == 2 + assert len(camera_infos) == 2 + assert len(runtime_events) == 2 + assert all(isinstance(message, MotorStateFrame) for message in motor_states) + assert all(isinstance(message, Image) for message in color_images) + assert all(isinstance(message, CameraInfo) for message in camera_infos) + assert all(isinstance(message, ObservationFrame) for message in runtime_events) + assert [event.inline_text for event in runtime_events] == ["reset", "step"] + assert [event.metadata for event in runtime_events] == [{"sequence": 0}, {"sequence": 1}] + assert color_images[0].data.shape == (2, 2, 3) + assert camera_infos[0].width == 2 + assert camera_infos[0].height == 2 + finally: + module._owner.stop() + + +def test_fake_runtime_module_rejects_wrong_robot_and_mode() -> None: + module = _make_local_fake_runtime_module(robot_id="testbot", dof=1, step_hz=10) + try: + try: + module.step( + StepRequest( + episode_id="episode-1", + tick_id=1, + action=MotorActionFrame(robot_id="other", names=["testbot/joint1"], q=[0.0]), + ) + ) + except ValueError as exc: + assert "unexpected robot_id" in str(exc) + else: + raise AssertionError("wrong robot id should fail") + + try: + module.step( + StepRequest( + episode_id="episode-1", + tick_id=1, + action=MotorActionFrame( + robot_id="testbot", + mode=CommandMode.TORQUE, + names=["testbot/joint1"], + q=[0.0], + ), + ) + ) + except ValueError as exc: + assert "unsupported command mode" in str(exc) + else: + raise AssertionError("wrong command mode should fail") + finally: + module._owner.stop() + + +def test_owner_thread_rejects_calls_after_stop() -> None: + owner = SimulatorOwnerThread(name="test-owner") + + assert owner.call(lambda: "ok") == "ok" + owner.stop() + + try: + owner.call(lambda: "late") + except RuntimeError as exc: + assert "stopped" in str(exc) + else: + raise AssertionError("owner thread should reject calls after stop") + + +def test_publish_output_supports_remote_transport_only_output() -> None: + transport = _TransportCapture() + output = _RemoteOutputLike(transport) + + publish_output(output, "message") + + assert transport.values == ["message"] + + +def _make_local_fake_runtime_module(*, robot_id: str, dof: int, step_hz: int) -> FakeRuntimeModule: + with patch.object(Module, "__init__", return_value=None): + module = FakeRuntimeModule(robot_id=robot_id, dof=dof, step_hz=step_hz) + module.motor_state = Out(MotorStateFrame, "motor_state", module) + module.color_image = Out(Image, "color_image", module) + module.camera_info = Out(CameraInfo, "camera_info", module) + module.runtime_event = Out(ObservationFrame, "runtime_event", module) + return module + + +class _TransportCapture: + def __init__(self) -> None: + self.values: list[str] = [] + + def publish(self, value: str) -> None: + self.values.append(value) + + +class _RemoteOutputLike: + def __init__(self, transport: _TransportCapture) -> None: + self.transport = transport diff --git a/dimos/benchmark/runtime/test_libero_pro_sidecar_profile.py b/dimos/benchmark/runtime/test_libero_pro_sidecar_profile.py index c63572a75b..7913902be7 100644 --- a/dimos/benchmark/runtime/test_libero_pro_sidecar_profile.py +++ b/dimos/benchmark/runtime/test_libero_pro_sidecar_profile.py @@ -16,14 +16,10 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from io import BytesIO -import json from pathlib import Path import sys -from threading import Thread -from typing import cast -from urllib.request import Request, urlopen import numpy as np import pytest @@ -37,7 +33,6 @@ from dimos_libero_pro_sidecar.server import ( LiberoProRuntimeConfig, LiberoProRuntimeState, - make_server, validate_assets, ) from dimos_runtime_protocol import EpisodeResetRequest, MotorActionFrame, StepRequest @@ -160,49 +155,6 @@ def test_libero_pro_asset_validation_does_not_bootstrap_by_default(tmp_path: Pat validate_assets(config) -def test_libero_pro_http_endpoints_with_stubbed_backend(tmp_path: Path) -> None: - config = _config(tmp_path) - state = LiberoProRuntimeState(config, backend=_FakeLiberoBackend()) - server = make_server( - LiberoProRuntimeConfig( - host="127.0.0.1", - port=0, - benchmark_name=config.benchmark_name, - bddl_root=config.bddl_root, - init_states_root=config.init_states_root, - camera_names=config.camera_names, - ), - state=state, - ) - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - base_url = f"http://127.0.0.1:{server.server_address[1]}" - try: - health = _get_json(f"{base_url}/health") - assert health["ok"] is True - assert _get_json(f"{base_url}/describe")["backend"] == "libero-pro" - reset = _post_json(f"{base_url}/reset", {"episode_id": "episode", "task_id": "task"}) - assert reset["episode_id"] == "episode" - step = _post_json( - f"{base_url}/step", - { - "episode_id": "episode", - "tick_id": 1, - "action": {"robot_id": "panda", "names": state.motor_names, "q": [0.2] * 8}, - }, - ) - assert step["success"] is True - observations = cast("Sequence[Mapping[str, object]]", step["observations"]) - image = next(frame for frame in observations if frame["stream"] == "agentview") - payload = urlopen(f"{base_url}{image['data_ref']}", timeout=5).read() - assert np.array_equal(np.load(BytesIO(payload), allow_pickle=False), _pure_color_image()) - assert _get_json(f"{base_url}/score")["success"] is True - finally: - server.shutdown() - thread.join(timeout=5) - server.server_close() - - def _config( tmp_path: Path, *, controller: str = "JOINT_POSITION", visualize: bool = False ) -> LiberoProRuntimeConfig: @@ -240,23 +192,3 @@ def _pure_color_image() -> np.ndarray: image[0, :, :] = [255, 0, 0] image[1, :, :] = [0, 255, 0] return image - - -def _get_json(url: str) -> dict[str, object]: - with urlopen(url, timeout=5) as response: - data = json.loads(response.read().decode("utf-8")) - assert isinstance(data, dict) - return data - - -def _post_json(url: str, payload: dict[str, object]) -> dict[str, object]: - request = Request( - url, - data=json.dumps(payload).encode("utf-8"), - headers={"content-type": "application/json"}, - method="POST", - ) - with urlopen(request, timeout=5) as response: - data = json.loads(response.read().decode("utf-8")) - assert isinstance(data, dict) - return data diff --git a/dimos/benchmark/runtime/test_robosuite_sidecar_profile.py b/dimos/benchmark/runtime/test_robosuite_sidecar_profile.py index a2c5c7294c..baabd6445d 100644 --- a/dimos/benchmark/runtime/test_robosuite_sidecar_profile.py +++ b/dimos/benchmark/runtime/test_robosuite_sidecar_profile.py @@ -17,11 +17,9 @@ from __future__ import annotations from collections.abc import Sequence -import importlib.util from io import BytesIO from pathlib import Path import sys -from types import ModuleType from typing import Protocol, cast import numpy as np @@ -38,24 +36,6 @@ ) from dimos_runtime_protocol import EpisodeResetRequest, MotorActionFrame, StepRequest -DEMO_SCRIPT = REPO_ROOT / "scripts" / "benchmarks" / "demo_robosuite_panda_lift.py" -_DEMO_SPEC = importlib.util.spec_from_file_location("demo_robosuite_panda_lift", DEMO_SCRIPT) -assert _DEMO_SPEC is not None -assert _DEMO_SPEC.loader is not None -_DEMO_MODULE = importlib.util.module_from_spec(_DEMO_SPEC) -sys.modules[_DEMO_SPEC.name] = _DEMO_MODULE -_DEMO_SPEC.loader.exec_module(_DEMO_MODULE) - - -class _PublishRerunObservations(Protocol): - def __call__(self, client: object, response_observations: object, publisher: object) -> int: ... - - -_publish_rerun_observations = cast( - "_PublishRerunObservations", - cast("ModuleType", _DEMO_MODULE).__dict__["_publish_rerun_observations"], -) - class _FakeControllers: def load_composite_controller_config(self, *, controller: str) -> dict[str, object]: @@ -90,26 +70,6 @@ class _Mocker(Protocol): def patch(self, target: str, **kwargs: object) -> object: ... -class _PayloadClient: - def __init__(self, state: RobosuiteRuntimeState) -> None: - self._state = state - - def payload(self, data_ref: str) -> bytes: - return self._state.payload_bytes(data_ref.removeprefix("/payloads/")) - - -class _CapturePublisher: - def __init__(self) -> None: - self.images: list[np.ndarray] = [] - self.fovs: list[float] = [] - self.frame_ids: list[str] = [] - - def publish_rgb(self, rgb: object, *, fov_y_deg: float, frame_id: str) -> None: - self.images.append(np.asarray(rgb).copy()) - self.fovs.append(fov_y_deg) - self.frame_ids.append(frame_id) - - def test_robosuite_panda_lift_profile_maps_actions_states_and_observations( mocker: _Mocker, ) -> None: @@ -183,14 +143,8 @@ def test_pure_color_camera_payload_round_trips_and_decodes_exactly(mocker: _Mock raw = np.load(BytesIO(payload), allow_pickle=False) assert np.array_equal(raw, _pure_color_image()) - publisher = _CapturePublisher() - published = _publish_rerun_observations(_PayloadClient(state), response.observations, publisher) - - assert published == 1 - assert len(publisher.images) == 1 - assert np.array_equal(publisher.images[0], np.flipud(_pure_color_image())) - assert publisher.fovs == [45.0] - assert publisher.frame_ids == ["agentview"] + assert image_frame.metadata["image_convention"] == "opengl" + assert image_frame.metadata["payload_sha256"] def _config() -> RobosuiteRuntimeConfig: diff --git a/dimos/benchmark/runtime/test_runtime_sidecar_modules.py b/dimos/benchmark/runtime/test_runtime_sidecar_modules.py new file mode 100644 index 0000000000..e0665b057a --- /dev/null +++ b/dimos/benchmark/runtime/test_runtime_sidecar_modules.py @@ -0,0 +1,378 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); + +"""Focused tests for first-class Robosuite/LIBERO-PRO runtime modules.""" + +from __future__ import annotations + +from io import BytesIO +import os +from pathlib import Path +import subprocess +import sys +import threading +from typing import ClassVar, Protocol, cast + +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[3] +PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" +ROBOSUITE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src" +LIBERO_PRO_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-libero-pro-sidecar" / "src" +for _src in (PROTOCOL_SRC, ROBOSUITE_SIDECAR_SRC, LIBERO_PRO_SIDECAR_SRC): + sys.path.insert(0, str(_src)) + +from dimos_libero_pro_sidecar.blueprint import libero_pro_runtime_blueprint +from dimos_libero_pro_sidecar.module import LiberoProRuntimeModule +from dimos_robosuite_sidecar.blueprint import robosuite_runtime_blueprint +from dimos_robosuite_sidecar.module import RobosuiteRuntimeModule +from dimos_runtime_protocol import EpisodeResetRequest, MotorActionFrame, StepRequest +from dimos_runtime_protocol.models import ( + EpisodeResetResponse, + MotorDescription, + MotorStateFrame, + ObservationFrame, + ObservationKind, + RobotMotorSurface, + RuntimeDescription, + ScoreOutput, + StepResponse, +) + +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image + + +class _Mocker(Protocol): + def patch(self, target: str, **kwargs: object) -> object: ... + + +class _CaptureOut: + def __init__(self) -> None: + self.values: list[object] = [] + + def publish(self, value: object) -> None: + self.values.append(value) + + +class _RuntimeModuleProtocol(Protocol): + motor_state: _CaptureOut + color_image: _CaptureOut + camera_info: _CaptureOut + runtime_event: _CaptureOut + + +class _StubRobosuiteState: + init_count: ClassVar[int] = 0 + + def __init__(self, config: object) -> None: + type(self).init_count += 1 + self.config = config + self._sequence = 0 + self.thread_ids: list[int] = [threading.get_ident()] + + def describe(self) -> RuntimeDescription: + self.thread_ids.append(threading.get_ident()) + return _description("robosuite") + + def reset(self, request: EpisodeResetRequest) -> object: + self.thread_ids.append(threading.get_ident()) + return _reset_response(request.episode_id, "robosuite") + + def step(self, request: StepRequest) -> StepResponse: + self.thread_ids.append(threading.get_ident()) + self._sequence += 1 + return _step_response(request, self._sequence) + + def score(self) -> ScoreOutput: + self.thread_ids.append(threading.get_ident()) + return ScoreOutput(episode_id="episode", success=True, score=1.0) + + def _camera_image(self) -> np.ndarray: + return _corrupt_image() + + def _camera_fov_deg(self) -> float: + return 45.0 + + def payload_bytes(self, payload_id: str) -> bytes: + assert payload_id == "agentview-000001-000001.npy" + return _npy_bytes(_image()) + + +class _StubLiberoState(_StubRobosuiteState): + def __init__(self, config: object) -> None: + super().__init__(config) + self._last_obs = {"agentview_image": _image()} + + def reset(self, request: EpisodeResetRequest) -> object: + self.thread_ids.append(threading.get_ident()) + return _reset_response(request.episode_id, "libero") + + def step(self, request: StepRequest) -> StepResponse: + self.thread_ids.append(threading.get_ident()) + self._sequence += 1 + return _step_response(request, self._sequence, image_convention=None) + + +class _FailingLiberoState: + def __init__(self, config: object) -> None: + raise RuntimeError("missing LIBERO assets") + + +def test_blueprint_helpers_place_only_runtime_module() -> None: + robosuite = robosuite_runtime_blueprint() + libero_runtime = PythonProjectRuntimeEnvironment( + name="libero-test", project=LIBERO_PRO_SIDECAR_SRC.parent + ) + libero = libero_pro_runtime_blueprint( + bddl_root="/tmp/bddl", init_states_root="/tmp/init", runtime=libero_runtime + ) + + assert [atom.module for atom in robosuite.blueprints] == [RobosuiteRuntimeModule] + assert isinstance( + robosuite.runtime_environment_registry.resolve("dimos-robosuite-runtime"), + PythonProjectRuntimeEnvironment, + ) + assert dict(robosuite.runtime_placement_map) == { + RobosuiteRuntimeModule: "dimos-robosuite-runtime" + } + assert [atom.module for atom in libero.blueprints] == [LiberoProRuntimeModule] + assert libero.runtime_environment_registry.resolve("libero-test") is libero_runtime + assert dict(libero.runtime_placement_map) == {LiberoProRuntimeModule: "libero-test"} + + +def test_importing_runtime_modules_does_not_import_heavy_dependencies() -> None: + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join( + [str(PROTOCOL_SRC), str(ROBOSUITE_SIDECAR_SRC), str(LIBERO_PRO_SIDECAR_SRC), str(REPO_ROOT)] + ) + code = """ +import importlib +import sys +for name in ('dimos_robosuite_sidecar.module', 'dimos_robosuite_sidecar.blueprint', 'dimos_libero_pro_sidecar.module', 'dimos_libero_pro_sidecar.blueprint'): + importlib.import_module(name) +heavy = {'robosuite', 'libero', 'torch'} & set(sys.modules) +if heavy: + raise SystemExit(f'heavy modules imported: {sorted(heavy)}') +""" + subprocess.run([sys.executable, "-c", code], check=True, env=env) + + +def test_robosuite_runtime_module_rpc_and_stream_outputs(mocker: _Mocker) -> None: + _StubRobosuiteState.init_count = 0 + mocker.patch( + "dimos_robosuite_sidecar.module.RobosuiteRuntimeState", side_effect=_StubRobosuiteState + ) + module = RobosuiteRuntimeModule() + _attach_outputs(module) + caller_thread_id = threading.get_ident() + + assert module._state is None + description = module.describe() + reset = module.reset(EpisodeResetRequest(episode_id="episode", task_id="task")) + step = module.step(_step_request()) + score = module.score() + + assert description.observation_streams == ["color_image", "camera_info", "runtime_event"] + assert description.metadata["backend_camera_streams"] == ["agentview"] + assert description.metadata["module_streams"]["color_image"] == "color_image" + assert reset.observations == [] + assert reset.runtime_description.observation_streams == [ + "color_image", + "camera_info", + "runtime_event", + ] + assert "sync-http" not in reset.runtime_description.capabilities + assert step.observations == [] + assert score.success is True + _assert_published_native_types(module, expected_image=np.flipud(_image())) + state = module._state + assert state is not None + assert _StubRobosuiteState.init_count == 1 + assert len(set(state.thread_ids)) == 1 + assert state.thread_ids[0] != caller_thread_id + module.stop() + + +def test_libero_pro_runtime_module_rpc_and_stream_outputs(mocker: _Mocker, tmp_path: Path) -> None: + _StubLiberoState.init_count = 0 + mocker.patch( + "dimos_libero_pro_sidecar.module.LiberoProRuntimeState", side_effect=_StubLiberoState + ) + module = LiberoProRuntimeModule(bddl_root=tmp_path, init_states_root=tmp_path) + _attach_outputs(module) + caller_thread_id = threading.get_ident() + + assert module._state is None + description = module.describe() + reset = module.reset(EpisodeResetRequest(episode_id="episode", task_id="task")) + step = module.step(_step_request()) + score = module.score() + + assert description.observation_streams == ["color_image", "camera_info", "runtime_event"] + assert description.metadata["backend_camera_streams"] == ["agentview"] + assert reset.observations == [] + assert "sync-http" not in reset.runtime_description.capabilities + assert step.observations == [] + assert score.score == 1.0 + _assert_published_native_types(module, expected_image=_image()) + assert module._state is not None + assert _StubLiberoState.init_count == 1 + assert len(set(module._state.thread_ids)) == 1 + assert module._state.thread_ids[0] != caller_thread_id + module.stop() + + +def test_libero_runtime_state_failures_happen_on_reset_not_deploy( + mocker: _Mocker, tmp_path: Path +) -> None: + mocker.patch( + "dimos_libero_pro_sidecar.module.LiberoProRuntimeState", side_effect=_FailingLiberoState + ) + module = LiberoProRuntimeModule(bddl_root=tmp_path, init_states_root=tmp_path) + _attach_outputs(module) + + assert module._state is None + try: + try: + module.reset(EpisodeResetRequest(episode_id="episode", task_id="task")) + except RuntimeError as exc: + assert "missing LIBERO assets" in str(exc) + else: + raise AssertionError("reset should surface runtime setup failure") + finally: + module.stop() + + +def _attach_outputs(module: _RuntimeModuleProtocol) -> None: + module.motor_state = _CaptureOut() + module.color_image = _CaptureOut() + module.camera_info = _CaptureOut() + module.runtime_event = _CaptureOut() + + +def _assert_published_native_types( + module: _RuntimeModuleProtocol, *, expected_image: np.ndarray +) -> None: + assert isinstance(module.motor_state.values[-1], MotorStateFrame) + assert isinstance(module.color_image.values[-1], Image) + assert isinstance(module.camera_info.values[-1], CameraInfo) + color_image = cast("Image", module.color_image.values[-1]) + np.testing.assert_array_equal(color_image.data, expected_image) + assert all(isinstance(value, ObservationFrame) for value in module.runtime_event.values) + events = cast( + "list[ObservationFrame]", + [value for value in module.runtime_event.values if isinstance(value, ObservationFrame)], + ) + assert {value.kind for value in events} >= { + ObservationKind.STATE, + ObservationKind.TEXT, + } + + +def _description(backend: str) -> RuntimeDescription: + return RuntimeDescription( + runtime_id=f"{backend}-runtime", + backend=backend, + capabilities=["sync-http", "step"], + robot_surfaces=[ + RobotMotorSurface( + robot_id="panda", + motors=[MotorDescription(name="panda/joint1", index=0)], + ) + ], + control_step_hz=20, + ) + + +def _reset_response(episode_id: str, backend: str) -> EpisodeResetResponse: + return EpisodeResetResponse( + episode_id=episode_id, + runtime_description=_description(backend), + observations=[ + _state_observation(), + _image_observation("opengl" if backend == "robosuite" else None), + ], + ) + + +def _step_request() -> StepRequest: + return StepRequest( + episode_id="episode", + tick_id=1, + action=MotorActionFrame(robot_id="panda", names=["panda/joint1"], q=[0.25]), + ) + + +def _step_response( + request: StepRequest, sequence: int, *, image_convention: str | None = "opengl" +) -> StepResponse: + return StepResponse( + episode_id=request.episode_id, + tick_id=request.tick_id, + motor_state=MotorStateFrame( + robot_id="panda", + names=["panda/joint1"], + q=[0.25], + dq=[0.0], + tau=[0.0], + sequence=sequence, + ), + observations=[_state_observation(), _image_observation(image_convention)], + success=True, + ) + + +def _image_observation(image_convention: str | None) -> ObservationFrame: + metadata: dict[str, object] = {"camera_name": "agentview", "fov_y_deg": 45.0} + if image_convention is not None: + metadata["image_convention"] = image_convention + return ObservationFrame( + stream="agentview", + kind=ObservationKind.IMAGE, + encoding="npy", + data_ref="/payloads/agentview-000001-000001.npy", + metadata=metadata, + ) + + +def _state_observation() -> ObservationFrame: + return ObservationFrame( + stream="robot_state", kind=ObservationKind.STATE, metadata={"source": "stub"} + ) + + +def _image() -> np.ndarray: + return np.array( + [ + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + [[10, 11, 12], [13, 14, 15], [16, 17, 18]], + ], + dtype=np.uint8, + ) + + +def _corrupt_image() -> np.ndarray: + return np.full((2, 3, 3), 255, dtype=np.uint8) + + +def _npy_bytes(value: np.ndarray) -> bytes: + buffer = BytesIO() + np.save(buffer, value) + return buffer.getvalue() diff --git a/dimos/simulation/runtime_client/http_client.py b/dimos/simulation/runtime_client/http_client.py deleted file mode 100644 index 84b101b591..0000000000 --- a/dimos/simulation/runtime_client/http_client.py +++ /dev/null @@ -1,100 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Synchronous HTTP client for benchmark runtime sidecars.""" - -from __future__ import annotations - -import time - -from dimos_runtime_protocol import ( - EpisodeResetRequest, - EpisodeResetResponse, - HealthResponse, - RuntimeDescription, - ScoreOutput, - StepRequest, - StepResponse, -) -import requests - - -class RuntimeSidecarClient: - """Minimal request/response client for the v1 runtime sidecar protocol.""" - - def __init__(self, base_url: str, *, timeout_s: float = 5.0) -> None: - self.base_url = base_url.rstrip("/") - self.timeout_s = timeout_s - - def health(self) -> HealthResponse: - response = requests.get(f"{self.base_url}/health", timeout=self.timeout_s) - _raise_for_status(response) - return HealthResponse.model_validate(response.json()) - - def wait_until_healthy( - self, *, timeout_s: float = 10.0, poll_s: float = 0.05 - ) -> HealthResponse: - deadline = time.monotonic() + timeout_s - last_error: requests.RequestException | ValueError | None = None - while time.monotonic() < deadline: - try: - return self.health() - except (requests.RequestException, ValueError) as exc: - last_error = exc - time.sleep(poll_s) - raise TimeoutError(f"runtime sidecar did not become healthy: {last_error}") - - def describe(self) -> RuntimeDescription: - response = requests.get(f"{self.base_url}/describe", timeout=self.timeout_s) - _raise_for_status(response) - return RuntimeDescription.model_validate(response.json()) - - def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: - response = requests.post( - f"{self.base_url}/reset", - json=request.model_dump(mode="json"), - timeout=self.timeout_s, - ) - _raise_for_status(response) - return EpisodeResetResponse.model_validate(response.json()) - - def step(self, request: StepRequest) -> StepResponse: - response = requests.post( - f"{self.base_url}/step", - json=request.model_dump(mode="json"), - timeout=self.timeout_s, - ) - _raise_for_status(response) - return StepResponse.model_validate(response.json()) - - def score(self) -> ScoreOutput: - response = requests.get(f"{self.base_url}/score", timeout=self.timeout_s) - _raise_for_status(response) - return ScoreOutput.model_validate(response.json()) - - def payload(self, data_ref: str) -> bytes: - path = data_ref if data_ref.startswith("/") else f"/{data_ref}" - response = requests.get(f"{self.base_url}{path}", timeout=self.timeout_s) - _raise_for_status(response) - return response.content - - -def _raise_for_status(response: requests.Response) -> None: - try: - response.raise_for_status() - except requests.HTTPError as exc: - body = response.text.strip() - if body: - raise requests.HTTPError(f"{exc}; response body: {body}", response=response) from exc - raise diff --git a/dimos/simulation/runtime_client/test_http_client.py b/dimos/simulation/runtime_client/test_http_client.py deleted file mode 100644 index cab4a7bcc7..0000000000 --- a/dimos/simulation/runtime_client/test_http_client.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for the synchronous runtime sidecar HTTP client.""" - -from __future__ import annotations - -from pathlib import Path -import sys - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[3] -PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" -sys.path.insert(0, str(PROTOCOL_SRC)) - -from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient - - -def test_wait_until_healthy_times_out_for_unavailable_sidecar() -> None: - client = RuntimeSidecarClient("http://127.0.0.1:9", timeout_s=0.01) - - with pytest.raises(TimeoutError): - client.wait_until_healthy(timeout_s=0.02, poll_s=0.001) diff --git a/dimos/simulation/runtime_module.py b/dimos/simulation/runtime_module.py new file mode 100644 index 0000000000..a146c26cdf --- /dev/null +++ b/dimos/simulation/runtime_module.py @@ -0,0 +1,191 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared helpers for simulator runtime modules.""" + +from __future__ import annotations + +from collections.abc import Callable +from concurrent.futures import Future +from dataclasses import dataclass +from queue import Queue +import threading +from typing import Generic, Protocol, TypeVar, cast + +from dimos_runtime_protocol.models import ( + EpisodeResetRequest, + EpisodeResetResponse, + ObservationFrame, + RuntimeDescription, + ScoreOutput, + StepRequest, + StepResponse, +) + +from dimos.core.stream import Out, Transport + +RuntimeDescribeResponse = RuntimeDescription +RuntimeResetRequest = EpisodeResetRequest +RuntimeResetResponse = EpisodeResetResponse +RuntimeStepRequest = StepRequest +RuntimeStepResponse = StepResponse +RuntimeScoreResponse = ScoreOutput +RuntimeEventFrame = ObservationFrame + +MOTOR_STATE_STREAM = "motor_state" +COLOR_IMAGE_STREAM = "color_image" +DEPTH_IMAGE_STREAM = "depth_image" +CAMERA_INFO_STREAM = "camera_info" +RUNTIME_EVENT_STREAM = "runtime_event" + +T = TypeVar("T") + + +@dataclass(frozen=True) +class _OwnerThreadCall(Generic[T]): + func: Callable[[], T] + future: Future[T] + + +class _OwnerThreadStop: + pass + + +_OwnerThreadItem = _OwnerThreadCall[object] | _OwnerThreadStop + + +class SimulatorOwnerThread: + """Serial executor for simulator APIs with thread-affinity constraints.""" + + def __init__(self, name: str) -> None: + self._queue: Queue[_OwnerThreadItem] = Queue() + self._thread = threading.Thread(target=self._run, name=name, daemon=True) + self._owner_thread_id: int | None = None + self._stopped = False + self._lock = threading.Lock() + self._thread.start() + + @property + def owner_thread_id(self) -> int | None: + return self._owner_thread_id + + def call(self, func: Callable[[], T]) -> T: + """Run `func` on the simulator owner thread and return its result.""" + + if self._owner_thread_id == threading.get_ident(): + return func() + future: Future[T] = Future() + with self._lock: + if self._stopped: + raise RuntimeError("Simulator owner thread has been stopped") + self._queue.put(cast("_OwnerThreadItem", _OwnerThreadCall(func=func, future=future))) + return future.result() + + def stop(self, timeout_s: float = 2.0) -> None: + """Stop the owner thread after it drains any currently running call.""" + + with self._lock: + if self._stopped: + return + self._stopped = True + self._queue.put(_OwnerThreadStop()) + if self._owner_thread_id != threading.get_ident(): + self._thread.join(timeout=timeout_s) + + def _run(self) -> None: + self._owner_thread_id = threading.get_ident() + while True: + item = self._queue.get() + if isinstance(item, _OwnerThreadStop): + return + try: + item.future.set_result(item.func()) + except BaseException as exc: + item.future.set_exception(exc) + + +class SimulatorExecutor(Protocol): + """Serial execution surface for simulator APIs with affinity constraints.""" + + @property + def owner_thread_id(self) -> int | None: ... + + def call(self, func: Callable[[], T]) -> T: ... + + def stop(self, timeout_s: float = 2.0) -> None: ... + + +class InlineSimulatorExecutor: + """Execute simulator calls inline on the caller thread. + + This is intended for visual simulator modes where the current process main + thread should own the MuJoCo/GLFW viewer context. + """ + + def __init__(self) -> None: + self._owner_thread_id = threading.get_ident() + self._stopped = False + + @property + def owner_thread_id(self) -> int | None: + return self._owner_thread_id + + def call(self, func: Callable[[], T]) -> T: + if self._stopped: + raise RuntimeError("Inline simulator executor has been stopped") + return func() + + def stop(self, timeout_s: float = 2.0) -> None: + self._stopped = True + + +def module_runtime_description( + description: RuntimeDescription, + *, + camera_streams: list[str] | None = None, +) -> RuntimeDescription: + """Rewrite sidecar-origin metadata to the DimOS module stream surface.""" + + capabilities = [cap for cap in description.capabilities if cap != "sync-http"] + metadata = dict(description.metadata) + if camera_streams is not None: + metadata["backend_camera_streams"] = camera_streams + metadata["module_streams"] = { + "motor_state": MOTOR_STATE_STREAM, + "color_image": COLOR_IMAGE_STREAM, + "camera_info": CAMERA_INFO_STREAM, + "runtime_event": RUNTIME_EVENT_STREAM, + } + observation_streams = [COLOR_IMAGE_STREAM, CAMERA_INFO_STREAM, RUNTIME_EVENT_STREAM] + return description.model_copy( + update={ + "capabilities": capabilities, + "observation_streams": observation_streams, + "metadata": metadata, + } + ) + + +def publish_output(output: Out[T] | object, value: T) -> None: + """Publish through local `Out` streams or deployed `RemoteOut` transports.""" + + publish = getattr(output, "publish", None) + if callable(publish): + cast("Callable[[T], None]", publish)(value) + return + + transport = getattr(output, "transport", None) + if transport is None: + raise RuntimeError("Output stream has no publish method or transport") + cast("Transport[T]", transport).publish(value) diff --git a/docs/development/runtime_sidecars.md b/docs/development/runtime_sidecars.md index bb094e4028..a1fceb1dd6 100644 --- a/docs/development/runtime_sidecars.md +++ b/docs/development/runtime_sidecars.md @@ -1,285 +1,165 @@ -# Runtime sidecars - -DimOS benchmark runtime sidecars keep heavy simulator dependencies outside the -main DimOS environment while still exercising the real DimOS control path. - -## Boundaries - -- `packages/dimos-runtime-protocol` contains only Pydantic protocol schemas, - compatibility checks, and codecs. It must not import `dimos`, Robosuite, - LIBERO, or OmniGibson. -- Sidecar packages import `dimos_runtime_protocol` and their backend SDKs, but - not the main `dimos` package. -- The remote runtime boundary is synchronous HTTP in this slice: - `/health`, `/describe`, `/reset`, `/step`, `/score`, and referenced - observation payloads under `/payloads/{id}`. -- The Robosuite sidecar intentionally serves HTTP requests on one thread. - MuJoCo / Robosuite render contexts are thread-sensitive; keeping `reset`, - `step`, offscreen camera payload capture, and the interactive viewer on the - same server thread avoids corrupted camera frames in visual mode. -- The local motor data plane is OS shared memory between the DimOS runtime demo - code and the `benchmark_runtime` `WholeBodyAdapter`. SHM is not the remote - sidecar protocol. - -## Fake runtime smoke demo - -The fake demo requires no Robosuite installation and validates protocol, -sidecar startup, local SHM, `WholeBodyAdapter`, and real `ControlCoordinator` -wiring. - -```bash -PYTHONPATH="packages/dimos-runtime-protocol/src" \ - uv run python scripts/benchmarks/demo_fake_runtime_sidecar.py -``` - -Expected output includes `"ok": true` and artifacts under -`artifacts/benchmark/fake-runtime-smoke/`. - -## Robosuite Panda Lift plumbing demo - -Run this from an environment that can import Robosuite 1.5.x and this monorepo. -The DimOS process still does not import Robosuite; the sidecar subprocess owns -Robosuite environment construction and stepping. Include the `manipulation` extra -when running demos that build `ManipulationModule`, because that module's default -planning backend uses Drake. - -```bash -uv run --with robosuite python scripts/benchmarks/demo_robosuite_panda_lift.py +# Simulator runtime modules + +DimOS benchmark runtime integrations keep heavy simulator dependencies outside +the main DimOS environment while exposing each simulator as a first-class DimOS +module at the blueprint boundary. The target shape is a **Simulator Runtime +Module** placed into a named Python project runtime environment (uv or +Pixi-backed uv), not a long-running HTTP runtime API. + +## Target boundary + +- `packages/dimos-runtime-protocol` contains backend-neutral Pydantic models, + compatibility helpers, and codecs. The models are reused by module RPCs; they + do not imply HTTP. +- Runtime packages may import `dimos` for module and blueprint definitions, but + coordinator-visible imports must remain simulator-import-safe. Heavy backend + SDK imports stay on the placed worker/runtime path. +- Control-plane operations are DimOS RPCs on the runtime module: `describe`, + `reset`, synchronous `step`, and `score`. +- Data-plane outputs are typed DimOS streams. Camera observations use + `Image`/`CameraInfo`; motor state and runtime events use typed protocol + models. Large NumPy arrays must not be returned through `step()` RPC payloads. +- Simulator mutation and camera capture run on a simulator owner thread. MuJoCo / + Robosuite render contexts are thread-sensitive; RPC handlers marshal work to + that owner thread rather than calling simulator APIs directly from pubsub/RPC + worker threads. + +The legacy HTTP runtime servers, `/payloads/{id}` image fetch endpoints, +`RuntimeSidecarClient`, and HTTP-first demos have been removed from the active +runtime path. Migration remains complete only while benchmark execution uses the +module RPC and stream surfaces described above. + +## Package-local blueprint helpers + +Each runtime package should expose a helper that registers its named Python +project runtime environment and places only the simulator runtime module there. +Callers use the helper instead of writing placement boilerplate. + +```python skip +from dimos_robosuite_sidecar.blueprint import robosuite_runtime_blueprint + +blueprint = robosuite_runtime_blueprint() ``` -To open the Robosuite viewer and watch the Panda receive a longer moving command -sequence: +The helper follows the standard runtime environment API: -```bash -uv run --with robosuite python scripts/benchmarks/demo_robosuite_panda_lift.py --visual -``` +```python skip +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment -The visual mode enables Robosuite's on-screen renderer in the sidecar process, -runs at least 600 ticks, and sends an oscillating joint-position command through -the same `ControlCoordinator` → SHM → runtime sidecar path. It requires a local -display/GUI-capable environment. Visual mode uses Robosuite/MuJoCo free-camera -viewer mode, so the viewport can be changed interactively with the viewer mouse -controls while the scripted motion runs. The named `agentview` camera is still -used for protocol observation metadata. +environment = PythonProjectRuntimeEnvironment( + name="dimos-robosuite-runtime", + project="packages/dimos-robosuite-sidecar", +) -To verify the camera observation path through DimOS streams and Rerun, run: - -```bash -uv run --with robosuite python scripts/benchmarks/demo_robosuite_panda_lift.py --rerun +blueprint = ( + autoconnect(RobosuiteRuntimeModule.blueprint()) + .runtime_environments(environment) + .runtime_placements({RobosuiteRuntimeModule: environment.name}) +) ``` -To verify the Robosuite camera payload path directly, without Rerun, run: +Prepare project runtimes explicitly before running placed modules. `dimos run` +is non-mutating and fails fast if the runtime project has not been prepared. ```bash -uv run --with robosuite python scripts/benchmarks/demo_robosuite_camera_payload_smoke.py --ticks 2 +dimos runtime prepare --runtime ``` -This starts the Robosuite sidecar, receives real Robosuite camera observation -frames, fetches each referenced `.npy` payload twice, decodes it with NumPy, and -asserts that the decoded array matches the sidecar-computed source hashes, -shape, dtype, and pixel summaries. Results are written to -`artifacts/benchmark/robosuite-camera-payload-smoke/camera_payload_smoke_summary.json`. -The same fetched/decoded images are also written as JPEGs under -`artifacts/benchmark/robosuite-camera-payload-smoke/images/`: `_raw.jpg` files -are the exact received arrays, and `_display_flipud.jpg` files apply the current -OpenGL display flip hypothesis for side-by-side inspection. - -If you need to verify the visualization transport independently of Robosuite, -run the deterministic color-bar smoke: +See `docs/usage/runtime_environments.md` for uv and Pixi-backed preparation +commands. -```bash -uv run python scripts/benchmarks/demo_rerun_color_smoke.py -``` +## Fake runtime smoke demo -The smoke script writes `artifacts/benchmark/rerun-color-smoke/color_smoke_summary.json` -and publishes a fixed RGB image through the same `.npy` decode, DimOS `Image` -LCM encode/decode, and Rerun bridge path. The expected display is top red, -middle green, bottom blue, with no color changes over time. If this smoke test -looks wrong, debug the DimOS/Rerun visualization path before debugging -Robosuite camera payloads. - -`--rerun` keeps the Robosuite viewer optional. The sidecar returns `agentview` -observation frames with raw NumPy `.npy` payload references, the script fetches -those payloads from `/payloads/{id}`, decodes them, vertically flips Robosuite's -default OpenGL-convention images for normal image-display semantics, and -publishes a private demo `Image(format=RGB)` / `CameraInfo` stream pair through -DimOS transports. The Rerun bridge uses an isolated gRPC port and an isolated LCM -port by default, so repeated demo runs do not mix with older recordings or other -DimOS camera topics. The Rerun server/viewer memory cap defaults to `128MB`, and -image logging is throttled to `10Hz` to avoid unbounded raw-image memory use. -Override with `--rerun-memory-limit`, `--rerun-grpc-port`, `--rerun-lcm-port`, or -`--rerun-max-hz` when needed. When `--rerun` is enabled, the demo also writes -sampled fetched camera payloads as JPEGs under -`artifacts/benchmark/robosuite-panda-lift/images/`; `_raw.jpg` files are exact -decoded payload arrays and `_display.jpg` files apply the current display -transform. Control the sampling with `--camera-jpeg-dump-every N` (`1` dumps -every tick, `<=0` disables). Use `--visual --rerun` if you want both the simulator -viewer and the DimOS/Rerun stream view at the same time. - -`agentview` is a scene/task camera, not a wrist camera. To inspect a wrist-mounted -Panda camera instead, run: +The fake demo requires no Robosuite or LIBERO installation and validates the +module-native runtime path: DimOS RPC control, lightweight `step()` responses, +typed stream publication, runtime-plan resolution, and artifact writing. ```bash -uv run --with robosuite python scripts/benchmarks/demo_robosuite_panda_lift.py --rerun --camera-name robot0_eye_in_hand +PYTHONPATH="packages/dimos-runtime-protocol/src:packages/dimos-fake-runtime-sidecar/src" \ + uv run python scripts/benchmarks/demo_fake_runtime_sidecar.py ``` -Useful Robosuite camera names for this demo include `agentview`, `frontview`, -`sideview`, `birdview`, `robot0_robotview`, and `robot0_eye_in_hand`. - -When `--visual --ticks N` is used, the script automatically raises the Robosuite -episode horizon to at least `N + 1`; otherwise long visual runs would hit the -default demo horizon and Robosuite would reject later `/step` calls after the env -terminates. You can still override this explicitly with `--horizon`. - -The demo uses `dimos/benchmark/runtime/configs/robosuite_panda_lift.json`, starts -`dimos_robosuite_sidecar.server`, resolves the Panda motor surface, builds a -Robosuite `Lift` + `Panda` env with a `JOINT_POSITION` arm controller plus -`GRIP`, runs a scripted joint-position target through `ControlCoordinator`, and -writes artifacts under `artifacts/benchmark/robosuite-panda-lift/`. +Expected output includes `"ok": true` and artifacts under +`artifacts/benchmark/fake-runtime-smoke/`. -If Robosuite is not installed, the script exits with an explicit sidecar health -failure and writes `robosuite_sidecar.log` with the import error. +## Robosuite Panda Lift plumbing demo -## Agentic manipulation Robosuite validation +The Robosuite demo should run against the placed `RobosuiteRuntimeModule` from +`packages/dimos-robosuite-sidecar`. The DimOS-facing path is module-native: -The agentic manipulation demo is a manual, script-hosted layer-2 validation. It -is not part of the default unit-test suite because it requires Robosuite and a -runtime sidecar process. The DimOS process still does not import Robosuite: the -script launches the Robosuite sidecar, resolves the runtime motor surface, builds -the local SHM bridge, then starts an in-script DimOS stack containing -`ControlCoordinator`, `ManipulationModule`, and `AgenticManipulationModule`. +- runtime metadata comes from `describe()`; +- episode setup comes from `reset()`; +- deterministic benchmark advancement uses synchronous `step()` RPCs with the + runtime-derived ordered `MotorActionFrame`; +- score/artifacts come from `score()` and the demo artifact writer; +- camera output is observed through `Image` and `CameraInfo` streams, not through + fetched `.npy` payloads. -```bash -uv run --extra manipulation --with robosuite python scripts/benchmarks/demo_agentic_manipulation_robosuite.py -``` - -This command opens the Robosuite viewer by default so a human can watch the -primitive validation run. The visual defaults keep stepping long enough to make -the gripper open/close commands and joint motion observable. Use `--headless` -only for CI or non-GUI environments: +Run it from the host DimOS environment. The demo builds the package-local +Robosuite blueprint and deploys `RobosuiteRuntimeModule` into the prepared +`packages/dimos-robosuite-sidecar` Python project runtime; the host environment +does not need to install Robosuite. ```bash -uv run --extra manipulation --with robosuite python scripts/benchmarks/demo_agentic_manipulation_robosuite.py --headless +uv run python scripts/benchmarks/demo_robosuite_panda_lift.py \ + --config dimos/benchmark/runtime/configs/robosuite_panda_lift.json ``` -For a slightly longer manual check, run: +Use `--visual` only in a GUI-capable environment. Use `--rerun` to verify the +normal DimOS stream visualization path; Rerun must consume the module-published +streams rather than direct runtime-boundary SDK logging or HTTP payload fetching. -```bash -uv run --extra manipulation --with robosuite python scripts/benchmarks/demo_agentic_manipulation_robosuite.py --ticks 600 --horizon 700 -``` - -The demo calls the universal agent-facing module API directly and fails hard if -`get_robot_state`, `open_gripper`, `close_gripper`, or a small safe -`move_to_joints` command does not report success. A background SHM-to-sidecar -stepping loop keeps simulator state moving while those blocking manipulation -calls execute. - -The direct RPC calls are synchronous. `move_to_joints` returns only after the -trajectory task reports completion, while `open_gripper` and `close_gripper` -return after the command has been accepted by the coordinator/adapter. The demo -therefore keeps the sidecar stepping for `--primitive-pause-s` seconds after each -gripper command and `--post-demo-s` seconds after the joint move so the viewer -does not close before those actions are visible. If you override `--ticks`, make -it large enough for those pauses or reduce the pause durations. - -Artifacts are written under the configured runtime artifact directory -(`artifacts/benchmark/robosuite-panda-lift/` by default), including the episode -config, runtime description, resolved runtime plan, derived runtime robot config, -stack summary, API call summary, motor trace, score when available, sidecar log, -and cleanup status. The script writes every artifact available even when the demo -fails partway through startup or API validation, so `api_call_summary.json`, -`motor_trace.json`, `failure.json`, and `cleanup_status.json` can be used to -diagnose partial runs. - -The stack summary also records two script-local fallbacks. First, the -`HardwareComponent` is constructed directly as `WHOLE_BODY` because -`RobotConfig.to_hardware_component()` derives manipulator hardware, while the -benchmark runtime adapter exposes a whole-body SHM motor plane. The task and -robot model still derive from `RobotConfig`. Second, the script writes a minimal -runtime URDF with conservative joint limits because the current Robosuite sidecar -description does not yet provide authoritative planning model metadata. - -`AgenticManipulationModule` itself is simulator-independent and imports only the -universal manipulation-control spec plus DimOS module/skill primitives. Robosuite -startup, runtime-plan resolution, SHM stepping, and artifact writing stay in this -script-hosted validation layer. MCP tool filtering, full LLM-agent execution, -Cartesian motion, and higher-level semantic manipulation skills remain future -work above this universal primitive facade. +The demo writes artifacts under +`artifacts/benchmark/robosuite-panda-lift/`, including runtime description, +resolved plan, motor trace, score when available, image/camera stream summaries, +and cleanup status. ## LIBERO-PRO registered-task runtime demo -LIBERO-PRO support follows the same runtime sidecar boundary as Robosuite, but the -sidecar must run in an environment that can import the LIBERO-PRO stack and has -prepared registered-suite assets. The DimOS process still does not import -LIBERO-PRO, Robosuite, or Torch; it starts `dimos_libero_pro_sidecar.server` in a -subprocess, resolves the described Panda motor surface, drives -`ControlCoordinator` through the SHM motor bridge, fetches `.npy` camera payloads, -and records sidecar-owned score output. +The LIBERO-PRO demo should run against the placed `LiberoProRuntimeModule` from +`packages/dimos-libero-pro-sidecar`. LIBERO-PRO assets remain explicit: startup +or reset validates the prepared asset layout, and asset download/bootstrap happens +only when requested by an explicit preparation command or flag. -Prepared assets are validated by default and are never downloaded or rearranged by -sidecar startup or `/health`. If asset preparation is desired, use the explicit -bootstrap path instead of relying on implicit startup mutation: +The DimOS-facing path is the same module-native contract as Robosuite: -```bash -uv run --with huggingface-hub python scripts/benchmarks/demo_libero_pro_runtime.py --prepare-assets -``` +- `describe()` exposes task/runtime metadata and the ordered Panda motor surface; +- `reset()` validates prepared BDDL/init-state assets and establishes episode + state synchronously; +- `step()` advances the backend on the simulator owner thread and returns + lightweight reward/done/success/motor metadata; +- camera output is observed through `Image` and `CameraInfo` streams; +- `score()` writes backend-owned task score metadata. -For already-prepared assets, run with a LIBERO-PRO-capable sidecar Python. If -that environment is separate from the main DimOS environment, point the demo at -it with `DIMOS_LIBERO_PRO_SIDECAR_PYTHON`: +Example prepared-asset run: ```bash LIBERO_CONFIG_PATH=/path/to/libero-config \ PYTHONPATH=/path/to/LIBERO-PRO \ -DIMOS_LIBERO_PRO_SIDECAR_PYTHON=/path/to/libero-env/bin/python \ uv run python scripts/benchmarks/demo_libero_pro_runtime.py ``` -To verify camera wiring live, run the LIBERO-PRO demo with the same DimOS Rerun -bridge path as the Robosuite demo: +Use `--visual` only when the underlying MuJoCo/Robosuite viewer stack can open a +local display. Use `--rerun` to verify the normal DimOS stream visualization path. -```bash -LIBERO_CONFIG_PATH=/path/to/libero-config \ -PYTHONPATH=/path/to/LIBERO-PRO \ -DIMOS_LIBERO_PRO_SIDECAR_PYTHON=/path/to/libero-env/bin/python \ -uv run python scripts/benchmarks/demo_libero_pro_runtime.py --rerun --camera-name agentview -``` - -For a live MuJoCo/Robosuite viewer window plus Rerun camera streaming, use -`--visual --rerun`: - -```bash -LIBERO_CONFIG_PATH=/path/to/libero-config \ -PYTHONPATH=/path/to/LIBERO-PRO \ -DIMOS_LIBERO_PRO_SIDECAR_PYTHON=/path/to/libero-env/bin/python \ -uv run python scripts/benchmarks/demo_libero_pro_runtime.py --visual --rerun --camera-name agentview -``` +The default config is +`dimos/benchmark/runtime/configs/libero_pro_goal_task0.json`. It declares backend +`libero-pro` with common runtime fields plus registered-suite options for task +selection, init-state selection, controller, cameras, horizon, and asset roots. +Missing BDDL or init-state assets should fail before episode stepping with a clear +module reset/setup error. -`--visual` defaults to a longer run and larger joint target amplitude than the -smoke config so the Panda arm motion is visible. The sidecar defaults -`MUJOCO_GL=glfw` for visual mode; override it in the environment if your display -stack needs a different MuJoCo backend. +## HTTP runtime removal audit -`--rerun` starts a private Rerun bridge and LCM bus, publishes fetched sidecar -camera payloads as DimOS `Image` / `CameraInfo` streams, and writes -`rerun_summary.json` plus raw/display JPEG samples under the demo artifact -directory. `--rerun-grpc-port`, `--rerun-lcm-port`, `--rerun-max-hz`, and -`--camera-jpeg-dump-every` mirror the Robosuite demo flags. Rerun is the -headless-friendly verification path when an interactive viewer cannot be opened. +When reviewing future changes, classify references to the old HTTP runtime path: -`LIBERO_CONFIG_PATH` must contain a `config.yaml` whose `bddl_files` and -`init_states` entries point at the prepared LIBERO-PRO assets. The extra -`PYTHONPATH` is only needed when running against a source checkout whose package -is not installed into the sidecar environment. +- **migrated**: fake, Robosuite, or LIBERO behavior now covered by module-native + RPCs and streams; +- **removed**: `RuntimeSidecarClient`, HTTP server entrypoints, `/payloads/{id}` + endpoints, camera payload smoke scripts/tests, and HTTP-first demo plumbing; +- **non-runtime usage**: unrelated HTTP URLs such as Rerun proxy links or web UI + documentation. -The default config is -`dimos/benchmark/runtime/configs/libero_pro_goal_task0.json`. It declares backend -`libero-pro` with common runtime fields plus backend-specific options for the -registered suite, task index, init-state index, controller, camera names, horizon, -and BDDL/init-state roots. Missing BDDL or init-state assets should fail before -episode reset with a clear sidecar validation error. - -The scripted demo is a plumbing acceptance check, not an agent task-success -benchmark. It can pass when protocol stepping, motor command/state flow, camera -payload retrieval, score collection, artifact writing, and cleanup succeed even if -the scripted servo target does not solve the selected LIBERO-PRO task. +Do not introduce new simulator data-plane protocols while removing the old HTTP +path. Prefer existing DimOS transports and typed messages. diff --git a/docs/usage/runtime_environments.md b/docs/usage/runtime_environments.md index 2bf1ba7d29..554b64ada1 100644 --- a/docs/usage/runtime_environments.md +++ b/docs/usage/runtime_environments.md @@ -150,6 +150,45 @@ The package's `pyproject.toml` should declare the worker dependencies needed by Phase 1 packages may depend on the root `dimos` package. A future split can replace that with a smaller worker runtime package. +### Simulator runtime modules + +Simulator integrations follow the project-worker pattern when their dependency +closure is too heavy or conflicting for the coordinator environment. The runtime +package exposes an import-safe `Module` class plus a package-local blueprint helper +that hides the placement boilerplate: + +```python skip +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment + +from my_sim_runtime.module import MySimRuntimeModule + + +def my_sim_runtime_blueprint(runtime: PythonProjectRuntimeEnvironment | None = None): + environment = runtime or PythonProjectRuntimeEnvironment( + name="my-sim-runtime", + project=Path("packages/my-sim-runtime"), + ) + return ( + autoconnect(MySimRuntimeModule.blueprint()) + .runtime_environments(environment) + .runtime_placements({MySimRuntimeModule: environment.name}) + ) +``` + +The module boundary is DimOS-native: + +- control plane: `describe`, `reset`, synchronous `step`, and `score` RPCs; +- data plane: typed streams such as `Out[Image]`, `Out[CameraInfo]`, motor state, + and runtime events; +- simulator ownership: reset, step, render, and camera capture are marshalled onto + the simulator owner thread when the backend has thread-affine render contexts. + +Do not use a long-running HTTP server or `/payloads/{id}` image fetches as the +target runtime boundary. Those paths are migration removal gates once the placed +module path covers import boundaries, runtime preparation, control RPCs, typed +data streams, and benchmark parity. + ## Native module runtime environments Native modules can reference a named native runtime environment instead of repeating executable/build settings in every config. diff --git a/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/.openspec.yaml b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/.openspec.yaml new file mode 100644 index 0000000000..d6b53dee55 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/design.md b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/design.md new file mode 100644 index 0000000000..cb22652eef --- /dev/null +++ b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/design.md @@ -0,0 +1,135 @@ +## Context + +DimOS currently has two overlapping simulator integration shapes: + +- First-class DimOS simulator modules, such as Unity and in-tree MuJoCo modules, expose typed DimOS streams at the blueprint boundary while keeping backend protocols private. +- Benchmark runtime sidecars for fake, Robosuite, and LIBERO-PRO run in separate environments and communicate through HTTP JSON, referenced array payloads, local SHM motor bridges, and script-local Rerun stream publishers. + +The runtime sidecar split successfully isolates heavy simulator dependencies from the main DimOS environment, but it leaves benchmark orchestration outside the module system and creates a second communication stack beside DimOS transports. The merged runtime environment work now provides the missing substrate: import-safe Python modules can be placed in named venv/uv/pixi-backed project environments through blueprint-level `runtime_environments()` and `runtime_placements()`. + +The target architecture is therefore a Simulator Runtime Module: a first-class DimOS Module hosted in a named runtime environment. The simulator package owns backend reset, stepping, observations, scoring, and heavy imports inside its worker environment. The main DimOS process owns blueprint composition, worker placement, lifecycle, typed streams, and RPC coordination. + +## Goals / Non-Goals + +**Goals:** + +- Migrate fake, Robosuite, and LIBERO-PRO benchmark runtimes from HTTP sidecars to placed DimOS Modules. +- Preserve dependency isolation: main DimOS imports must not require Robosuite, LIBERO-PRO, Torch, or simulator asset stacks. +- Use named `PythonProjectRuntimeEnvironment` entries, with optional Pixi-backed uv environments, for simulator packages that need external native/Python dependencies. +- Use package-local blueprint helpers to hide repeated placement boilerplate while preserving the standard blueprint-level placement API. +- Use DimOS RPC for benchmark control-plane calls: `describe`, `reset`, synchronous `step`, and `score`. +- Use DimOS-native typed streams/transports for large or continuous data: RGB/depth images are `Image` messages, camera models are `CameraInfo` messages, and motor state snapshots/runtime events use normal DimOS stream message types. +- Preserve current runtime motor action semantics: `step()` consumes the runtime-derived ordered motor action frame rather than backend-native opaque action vectors. +- Preserve simulator thread-affinity invariants by marshalling RPC work onto the simulator owner thread before calling backend reset/step/render APIs. +- Treat HTTP server/client removal as a migration success gate, not a preserved runtime mode. + +**Non-Goals:** + +- Do not create, sync, or mutate pixi/uv environments during `dimos run`; environment preparation remains explicit through `dimos runtime prepare` or documented commands. +- Do not make simulator placement an intrinsic `Module` class flag; placement remains blueprint-level. +- Do not introduce a new bespoke simulator transport. +- Do not require remote/multi-machine runtime deployment in this migration. +- Do not replace backend-neutral runtime protocol models immediately; reuse them as schema/control payloads where useful while moving the transport boundary into DimOS. +- Do not require benchmark task success by an agent; plumbing demos validate runtime behavior, not task-solving quality. + +## Decisions + +### 1. Target shape: Simulator Runtime Module, not HTTP sidecar + +Each migrated runtime package exposes an import-safe DimOS Module class, for example `RobosuiteRuntimeModule` or `LiberoProRuntimeModule`. The coordinator imports the module class without importing simulator SDKs. Worker-side runtime methods lazily construct backend state inside the placed runtime environment. + +Alternative considered: keep the HTTP sidecar and only standardize launch through the runtime environment registry. This preserves existing code but keeps a second control/data transport stack and prevents normal DimOS stream composition, so it is not the desired migration outcome. + +### 2. Placement is hidden by package-local blueprint helpers + +Callers should normally use a simulator package helper rather than manually writing runtime environment registration and placement for every blueprint: + +```python +robosuite_runtime_blueprint(...) +``` + +The helper registers a default `PythonProjectRuntimeEnvironment` and returns a blueprint with `.runtime_environments(environment)` and `.runtime_placements({RobosuiteRuntimeModule: environment.name})`. Advanced callers can pass an override runtime environment. + +Alternative considered: module-local deployment flags or a global sidecar registry. This conflicts with the current merged API and makes placement less composable across blueprints. + +### 3. Control plane uses synchronous DimOS RPC + +Runtime control calls are module RPCs: + +- `describe()` reports protocol/capability metadata, motor surfaces, streams, and backend/task metadata. +- `reset(request)` establishes a benchmark episode and returns reset metadata or fails synchronously for setup errors such as missing LIBERO assets. +- `step(request)` advances one coordinated benchmark tick and blocks until the simulator step has completed. +- `score(request)` returns score/artifact metadata after episode completion or timeout. + +Synchronous `step()` is intentional. DimOS RPC is already request/response over LCM pubsub, and benchmark control needs step completion before the runner can continue. Avoiding HTTP removes the separate request stack without forcing an asynchronous tick protocol. + +Alternative considered: stream-driven tick commands. This is useful for autonomous or high-throughput simulation loops but complicates benchmark control that requires deterministic request/response stepping. + +### 4. Data plane uses DimOS-native image streams, not RPC payloads + +Large observations do not travel in `step()` responses. `step()` returns control/evaluation metadata such as episode id, tick id, reward, done/success, status, error details, and observation sequence/timestamp references. RGB images and depth images publish as `Out[Image]`; camera models publish as `Out[CameraInfo]`; point clouds, motor states, and runtime events publish through their typed DimOS `Out[...]` streams. Simulator internals may use NumPy arrays, but the module boundary should wrap image arrays in DimOS `Image` messages rather than exposing raw `np.ndarray` payloads. + +Transport remains a blueprint choice. `LCMTransport(..., Image)` can carry raw `Image` message bytes for moderate rates; `JpegLcmTransport` or `JpegShmTransport` can be used for RGB camera streams where lossy compression is acceptable; raw `Image` over SHM/pickle-SHM is preferred for same-host high-rate images or depth data where JPEG is inappropriate. + +Alternative considered: include arrays or payload references in RPC responses and fetch them through HTTP-like payload endpoints. That preserves current sidecar behavior but keeps the payload-fetch path and duplicates stream publication work. + +### 5. Preserve runtime motor action frames during migration + +The module `step()` input keeps the current `MotorActionFrame` semantics: robot id, command mode, ordered motor names, q/dq/kp/kd/tau fields, and sequence. This represents the simulator runtime's declared DimOS-facing whole-body motor surface. It is not a backend-native action vector. + +Alternative considered: switch immediately to existing `MotorCommandArray` stream messages. That may become desirable for deeper whole-body unification, but forcing it into the first migration risks coupling simulator runtime parity to a separate motor API redesign. + +### 6. Simulator ownership thread protects backend APIs + +Module RPC handlers must not directly call Robosuite/MuJoCo/LIBERO rendering and stepping APIs from the LCMRPC thread-pool thread. Each runtime module owns a simulator executor/thread or main-thread loop. RPC handlers enqueue/marshal work to that owner and wait for completion; the owner thread performs reset/step/render/publish operations. + +If a visual backend requires process main-thread ownership, the worker/runtime support should allow a dedicated main-thread module worker mode rather than hiding backend calls in arbitrary callback threads. + +Alternative considered: call backend APIs directly from RPC handlers for simplicity. This violates known Robosuite/MuJoCo render-context constraints and risks corrupted frames or crashes. + +### 7. HTTP removal is a success gate + +The change is not complete while runtime HTTP servers, `RuntimeSidecarClient`, HTTP payload endpoints, or HTTP-first demo launch paths remain in active use. Deletion is a success gate: fake, Robosuite, and selected LIBERO-PRO module paths must cover import boundaries, runtime placement/preparation, control-plane calls, data-plane streams, and benchmark parity, then the old HTTP server/client surfaces are removed. + +Alternative considered: leave the old HTTP surface in place after module migration. That would create two simulator runtime products and undermine the goal of unifying communication around DimOS RPC and streams. + +## Risks / Trade-offs + +- **Thread affinity bugs** → Use simulator owner-thread marshalling and add tests/smoke demos that exercise reset, step, render, and publish through the module RPC path. +- **Backpressure from camera streams** → Treat camera/depth streams as normal `Image`/`CameraInfo` DimOS streams with transport selection and throttling; avoid large RPC payloads. +- **Environment drift** → Keep `dimos run` non-mutating and fail fast with actionable `dimos runtime prepare` guidance when prepared `.venv/bin/python` or project files are missing. +- **Import boundary regressions** → Keep coordinator import tests that import simulator module/blueprint helpers without heavy simulator dependencies installed. +- **Ambiguous reset failure semantics** → Make `reset()` the synchronous authority for episode setup; asset/config failures return through RPC errors rather than event-topic timeouts. +- **Migration limbo with two APIs** → Make HTTP removal part of the success criteria, not optional cleanup. +- **Motor API churn** → Preserve `MotorActionFrame` initially and document it as the module control payload; defer any `MotorCommandArray` convergence to a separate change. +- **Script/demo churn** → Migrate fake first, then Robosuite Panda Lift, then LIBERO-PRO so each phase has a working demonstration before deleting old HTTP code. + +## Migration Plan + +1. Add module-native fake runtime package support first. + - Add an import-safe `FakeRuntimeModule` wrapping existing fake runtime state. + - Add a package-local blueprint helper using `PythonProjectRuntimeEnvironment` or current/default env where appropriate. + - Prove RPC describe/reset/step/score and `Image`/`CameraInfo` stream outputs without simulator dependencies. +2. Migrate Robosuite Panda Lift. + - Evolve `packages/dimos-robosuite-sidecar` in place with `module.py` and `blueprint.py`. + - Wrap existing `RobosuiteRuntimeState` logic rather than rewriting backend mapping first. + - Marshal reset/step/render/publish onto the simulator owner thread. + - Publish `color_image`, optional `depth_image`, `camera_info`, and motor state through DimOS streams. +3. Migrate LIBERO-PRO. + - Evolve `packages/dimos-libero-pro-sidecar` in place. + - Preserve explicit asset preparation and make missing assets fail through `reset()`/setup RPC. + - Keep real LIBERO-PRO coverage optional/manual while contract tests use stubs. +4. Move demos and tests to module-native paths. + - Replace HTTP launch/client usage in scripted demos with blueprint helpers and `dimos runtime prepare` guidance. + - Replace SHM/payload-fetch/Rerun bridge expectations with module streams. +5. Remove HTTP runtime surfaces as a success gate. + - Remove HTTP entrypoints, `RuntimeSidecarClient`, HTTP payload endpoint tests, and HTTP-first docs/spec language. + - Keep backend-neutral protocol models if they still serve as reusable schema payloads. + +Rollback before the removal step means continuing work on the module-native path until it reaches parity. After removal, rollback means restoring the old HTTP surface from version control; removal should happen only when module-native demos, tests, and docs cover the required behavior. + +## Open Questions + +- Should score outputs become only RPC return values, or should selected score summaries also publish as runtime event streams for observability? +- Which concrete DimOS message types should represent runtime events and observation availability metadata in phase 1? +- Should Robosuite/LIBERO module packages initially depend on full `dimos` in their project runtime, matching venv packaging phase 1, or should they split a smaller runtime module support dependency later? diff --git a/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/proposal.md b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/proposal.md new file mode 100644 index 0000000000..761f3f4b44 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/proposal.md @@ -0,0 +1,33 @@ +## Why + +Simulator benchmark integrations currently sit behind HTTP runtime sidecars plus ad hoc SHM, payload-fetch, launch-script, and Rerun bridge paths. The newly added named runtime environment and venv/pixi module worker support makes it possible to move these runtimes to first-class DimOS Modules while preserving dependency isolation for Robosuite, LIBERO-PRO, and future simulator stacks. + +## What Changes + +- Introduce Simulator Runtime Modules: import-safe DimOS Modules hosted in named Python project runtime environments, with simulator-heavy imports executed only inside the placed worker. +- Add package-local simulator runtime blueprint helpers that register a default `PythonProjectRuntimeEnvironment` and place the simulator runtime module using the standard blueprint-level placement API. +- Move runtime control to DimOS RPC: `describe`, `reset`, synchronous `step`, and `score` are module RPCs rather than HTTP endpoints. +- Move runtime data to DimOS-native typed streams: camera images and depth publish as `dimos.msgs.sensor_msgs.Image`, camera models publish as `CameraInfo`, and motor states/runtime events flow through normal DimOS stream transports rather than raw NumPy RPC payloads, HTTP payload fetches, or script-local Rerun bridges. +- Preserve current runtime motor action semantics: `step()` accepts the runtime-derived ordered motor action frame and returns control/evaluation metadata while large observations publish on streams. +- Make HTTP removal a success gate for the migration: HTTP runtime sidecar servers, `RuntimeSidecarClient`, HTTP payload endpoints, and HTTP-first demo launch paths are removed as part of the change rather than preserved as a parallel runtime path. +- **BREAKING**: HTTP runtime sidecar APIs and launch paths are removed after their behavior is covered by Simulator Runtime Modules. + +## Capabilities + +### New Capabilities + +- `simulator-runtime-modules`: Defines first-class simulator runtime modules hosted in named runtime environments, their control-plane RPCs, data-plane streams, placement helpers, stepping/thread-affinity rules, and migration/deletion gates. + +### Modified Capabilities + +- `runtime-robosuite-sidecar`: Reframe Robosuite from an HTTP sidecar target to a package that provides a Simulator Runtime Module and remove its HTTP server boundary once module coverage lands. +- `runtime-libero-pro-sidecar`: Reframe LIBERO-PRO from an HTTP sidecar target to a package that provides a Simulator Runtime Module, preserving asset validation/reset behavior through module RPCs and removing its HTTP server boundary. +- `runtime-scripted-demos`: Replace HTTP/SHM/payload-fetch demo acceptance with module-native placement, RPC stepping, DimOS stream observations, and removal of script-local sidecar orchestration. +- `runtime-protocol`: Preserve backend-neutral runtime models as shared schema/control payloads while decoupling the target transport from HTTP JSON and payload-fetch endpoints. + +## Impact + +- Affected packages: `packages/dimos-fake-runtime-sidecar`, `packages/dimos-robosuite-sidecar`, `packages/dimos-libero-pro-sidecar`, and later simulator runtime packages. +- Affected DimOS runtime code: `dimos/simulation/runtime_client/*`, runtime demo scripts under `scripts/benchmarks/`, typed stream/Rerun integration paths, and whole-body runtime adapter plumbing. +- Affected environment configuration: simulator packages will use `PythonProjectRuntimeEnvironment`, optional Pixi-backed uv project environments, `dimos runtime prepare`, and blueprint-level `.runtime_environments()` / `.runtime_placements()`. +- Affected tests/specs/docs: runtime sidecar import-boundary tests, HTTP endpoint tests, runtime scripted demo specs, runtime environment docs, runtime sidecar docs, and new module-native parity coverage. diff --git a/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-libero-pro-sidecar/spec.md b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-libero-pro-sidecar/spec.md new file mode 100644 index 0000000000..b4a6b68a20 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-libero-pro-sidecar/spec.md @@ -0,0 +1,57 @@ +## MODIFIED Requirements + +### Requirement: LIBERO-PRO sidecar package +The system SHALL evolve the first-class LIBERO-PRO runtime package into an import-safe package that provides a Simulator Runtime Module for LIBERO-PRO execution and removes the existing HTTP server boundary as part of migration success. + +#### Scenario: LIBERO-PRO module imports without backend dependencies +- **WHEN** a developer imports the LIBERO-PRO runtime module class or blueprint helper in a normal DimOS development environment without LIBERO-PRO installed +- **THEN** the import succeeds without importing `libero`, `robosuite`, `torch`, or backend asset libraries + +#### Scenario: LIBERO-PRO runtime installs in isolated environment +- **WHEN** a developer prepares the LIBERO-PRO runtime package in a LIBERO-PRO-compatible Python project runtime environment +- **THEN** the placed DimOS worker can import DimOS worker runtime support, runtime protocol models, LIBERO-PRO, and required backend dependencies + +#### Scenario: HTTP entrypoint is removed +- **WHEN** the LIBERO-PRO Simulator Runtime Module covers runtime description, reset, step, observation streams, score, and demo execution +- **THEN** the package no longer exposes an HTTP runtime server entrypoint for benchmark execution + +### Requirement: LIBERO-PRO asset validation and bootstrap +The system SHALL support explicit opt-in LIBERO-PRO runtime asset bootstrap while requiring module reset/setup validation to report prepared asset failures without downloading or mutating local asset layout by default. + +#### Scenario: Prepared assets validate successfully +- **WHEN** required BDDL and init-state assets exist for the selected registered suite task +- **THEN** module reset or setup validation reports the assets as usable without modifying them + +#### Scenario: Missing assets fail clearly +- **WHEN** required BDDL or init-state assets are missing for the selected registered suite task +- **THEN** module `reset()` fails synchronously with a clear validation error that identifies the missing asset category + +#### Scenario: Asset bootstrap is explicit +- **WHEN** a developer requests asset preparation through an explicit bootstrap command or demo flag +- **THEN** the system may retrieve and stage supported external assets and then validates the resulting layout before module reset uses the assets + +### Requirement: LIBERO-PRO step ownership and observation export +The LIBERO-PRO runtime package SHALL own backend-native environment reset and step calls inside its Simulator Runtime Module and SHALL translate runtime motor action frames into LIBERO-PRO actions while exporting motor state, reward, done, success, and observations through module RPC metadata and DimOS-native typed streams. + +#### Scenario: LIBERO-PRO reset applies init state through RPC +- **WHEN** DimOS calls module `reset()` for a configured LIBERO-PRO registered task +- **THEN** the module resets the environment, applies the selected init state on the simulator owner thread, and returns initial task and motor metadata + +#### Scenario: Motor step advances LIBERO-PRO through RPC +- **WHEN** DimOS calls module `step()` with a motor position action frame for the described Panda motor surface +- **THEN** the module maps the action to the LIBERO-PRO environment step on the simulator owner thread and returns reward, done, success if available, and lightweight step metadata + +#### Scenario: Camera observation publishes as Image and CameraInfo streams +- **WHEN** a LIBERO-PRO step produces a configured camera observation +- **THEN** the module publishes the camera output as `Image` and camera metadata as `CameraInfo` through DimOS streams without requiring DimOS to fetch a `.npy` payload from an HTTP endpoint or receive a raw NumPy array in the step response + +### Requirement: LIBERO-PRO verification split +The system SHALL verify LIBERO-PRO module-runtime behavior with always-on contract tests that do not require real LIBERO-PRO dependencies or data, and SHALL keep real LIBERO-PRO execution behind optional/manual integration coverage. + +#### Scenario: Normal CI runs without LIBERO-PRO data +- **WHEN** normal test suites run in the main DimOS development environment +- **THEN** they verify import boundaries, blueprint helper placement, runtime environment selection, stubbed module RPCs, action-surface failures, and score shape without requiring LIBERO-PRO assets or dependencies + +#### Scenario: Manual integration exercises real LIBERO-PRO module +- **WHEN** a developer runs the optional real LIBERO-PRO integration with prepared dependencies and assets +- **THEN** it launches the placed Simulator Runtime Module, runs reset and synchronous step RPCs for one registered task, observes camera stream publication, and writes score and artifacts diff --git a/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-protocol/spec.md b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-protocol/spec.md new file mode 100644 index 0000000000..473e97f820 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-protocol/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: Shared runtime protocol package +The system SHALL provide lightweight backend-neutral runtime protocol models that can be reused by DimOS and simulator runtime packages without requiring HTTP transport, the main DimOS package in schema-only contexts, or any simulator backend SDK. + +#### Scenario: Runtime package imports protocol without simulator backend +- **WHEN** a simulator runtime package imports protocol models for validation or compatibility logic +- **THEN** it can import those models without importing Robosuite, LIBERO-PRO, OmniGibson, or backend-specific dependencies + +#### Scenario: DimOS module boundary reuses protocol models +- **WHEN** a Simulator Runtime Module exposes RPC request or response payloads based on runtime protocol models +- **THEN** the models describe backend-neutral runtime semantics without implying HTTP JSON transport or payload-fetch endpoints + +### Requirement: Protocol compatibility handshake +The runtime protocol SHALL include protocol version and capability metadata that simulator runtimes can report through module-native description RPCs or compatibility handshakes so DimOS can fail fast on incompatible protocol versions or unsupported capabilities. + +#### Scenario: Compatible module runtime describes itself +- **WHEN** DimOS calls `describe()` on a Simulator Runtime Module using a compatible protocol version +- **THEN** the runtime description includes protocol version and capability metadata that DimOS records in artifacts + +#### Scenario: Incompatible runtime is rejected +- **WHEN** DimOS describes a simulator runtime using an incompatible protocol version or unsupported capabilities +- **THEN** prelaunch or runtime setup fails before benchmark stepping and records the incompatibility reason + +### Requirement: Binary-friendly observation transport +The runtime protocol SHALL support image, depth, segmentation, and object/state observation metadata without requiring large image tensors, raw NumPy arrays, or nested JSON lists to be encoded in RPC responses. + +#### Scenario: Image observation uses DimOS stream metadata +- **WHEN** a Simulator Runtime Module publishes an RGB image observation on a DimOS stream +- **THEN** associated observation metadata identifies the `Image` stream, encoding, shape, dtype, sequence, or timestamp sufficient for consumers and artifacts to correlate the stream output + +#### Scenario: Camera model uses CameraInfo stream +- **WHEN** a Simulator Runtime Module publishes an image stream with camera intrinsics +- **THEN** the camera model is published as `CameraInfo` rather than embedded as ad hoc metadata in the step response + +#### Scenario: HTTP payload reference is removed from target path +- **WHEN** a runtime observation is produced by a migrated Simulator Runtime Module +- **THEN** the target path uses stream metadata and DimOS stream outputs rather than HTTP payload references diff --git a/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-robosuite-sidecar/spec.md b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-robosuite-sidecar/spec.md new file mode 100644 index 0000000000..1d8c9af088 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-robosuite-sidecar/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: Robosuite sidecar package +The system SHALL evolve the first-class Robosuite runtime package into an import-safe package that provides a Simulator Runtime Module for Robosuite execution and removes the existing HTTP server boundary as part of migration success. + +#### Scenario: Robosuite package imports in main DimOS environment +- **WHEN** a developer imports the Robosuite runtime module class or blueprint helper in the main DimOS environment +- **THEN** the import succeeds without importing Robosuite, MuJoCo renderer state, or backend-only dependencies + +#### Scenario: Robosuite runtime installs in isolated environment +- **WHEN** a developer prepares the Robosuite runtime package in a Robosuite-compatible Python project runtime environment +- **THEN** the placed DimOS worker can import DimOS worker runtime support, runtime protocol models, and Robosuite backend dependencies + +#### Scenario: HTTP entrypoint is removed +- **WHEN** the Robosuite Simulator Runtime Module covers runtime description, reset, step, observation streams, score, and demo execution +- **THEN** the package no longer exposes an HTTP runtime server entrypoint for benchmark execution + +### Requirement: Robosuite step ownership +The Robosuite runtime package SHALL own backend-native `env.reset()` and `env.step(action)` calls inside its Simulator Runtime Module and SHALL translate between runtime motor action frames, runtime state metadata, and Robosuite action/observation structures. + +#### Scenario: Motor position step advances Robosuite through RPC +- **WHEN** DimOS calls module `step()` with a motor position action frame for the described Panda motor surface +- **THEN** the module maps it to the Robosuite action vector, steps the environment on the simulator owner thread, and returns reward, done, success if available, and lightweight step metadata + +#### Scenario: Robosuite APIs stay on owner thread +- **WHEN** the module handles reset, step, render, or camera capture +- **THEN** those backend operations run on the simulator owner thread rather than directly on an RPC or stream callback thread + +### Requirement: Observation export +The Robosuite runtime package SHALL expose configured Robosuite camera and state observations through DimOS-native typed streams from its Simulator Runtime Module, with RPC responses containing only lightweight observation metadata. + +#### Scenario: Agentview camera is published as Image and CameraInfo streams +- **WHEN** the episode config enables the `agentview` camera and a step produces a frame +- **THEN** the module publishes the camera output as `Image` and camera metadata as `CameraInfo` through DimOS streams without requiring DimOS to fetch a `.npy` payload from an HTTP endpoint or receive a raw NumPy array in the step response + +#### Scenario: Step response references published observation +- **WHEN** a step produces an observation frame +- **THEN** the step response includes sequence, timestamp, or stream metadata sufficient to correlate the step with stream output without embedding image tensors diff --git a/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-scripted-demos/spec.md b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-scripted-demos/spec.md new file mode 100644 index 0000000000..06e4c05683 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/runtime-scripted-demos/spec.md @@ -0,0 +1,58 @@ +## MODIFIED Requirements + +### Requirement: Fake sidecar smoke demo +The system SHALL include a fake simulator runtime smoke demo that validates module-native protocol/control behavior, runtime placement where applicable, DimOS stream data flow, ControlCoordinator integration, and artifact output without requiring Robosuite. + +#### Scenario: Fake demo completes in normal DimOS environment +- **WHEN** a developer runs the fake simulator runtime demo in the normal DimOS development environment +- **THEN** the demo completes a configured number of synchronous module steps and writes episode config, resolved runtime plan, module trace summary, motor trace, score output, and logs + +#### Scenario: Fake demo exercises motor command/state flow +- **WHEN** the fake demo runs a scripted motor command sequence +- **THEN** commands flow through the module-native step path and motor states flow back into the DimOS side through the accepted module/stream contract + +### Requirement: Robosuite Panda Lift plumbing demo +The system SHALL include a Robosuite Panda Lift plumbing demo that validates the placed Robosuite Simulator Runtime Module, runtime description, derived motor mapping, synchronous step RPC, DimOS observation streams, score collection, and artifact output. + +#### Scenario: Robosuite demo starts placed runtime module +- **WHEN** a developer runs the Robosuite Panda Lift demo with a prepared compatible Robosuite Python project runtime environment +- **THEN** the demo uses the package-local blueprint helper, starts the placed Simulator Runtime Module, obtains runtime metadata, runs the scripted sequence, collects artifacts, and tears down the runtime + +#### Scenario: Robosuite joint state changes from scripted command +- **WHEN** the Robosuite demo sends a scripted motor position command sequence through module `step()` +- **THEN** the returned Robosuite-derived motor state metadata or published state stream changes consistently with the command sequence and is recorded in the motor trace + +#### Scenario: Robosuite observation stream is exported as Image and CameraInfo +- **WHEN** the Robosuite demo enables a camera observation stream +- **THEN** DimOS-side artifacts or logs show that at least one `Image` frame and associated `CameraInfo` were published through the module's DimOS stream outputs + +#### Scenario: Robosuite camera appears in Rerun through DimOS streams +- **WHEN** a developer runs the Robosuite demo with Rerun stream visualization enabled +- **THEN** Rerun displays camera output consumed from normal DimOS streams rather than from direct runtime-boundary Rerun SDK logging or HTTP payload fetching + +#### Scenario: Rerun demo stream is isolated and bounded +- **WHEN** a developer runs the Robosuite demo with Rerun stream visualization enabled repeatedly or alongside other DimOS publishers +- **THEN** the demo uses isolated Rerun and local DimOS transport settings for its visualization path and applies a bounded Rerun memory limit so old recordings or unrelated camera topics do not mix with the demo stream + +### Requirement: LIBERO-PRO full-control runtime demo +The system SHALL include a LIBERO-PRO runtime demo that validates the placed LIBERO-PRO Simulator Runtime Module, runtime description, registered task reset, synchronous step RPC, ControlCoordinator integration, camera stream export, score collection, artifact output, and teardown. + +#### Scenario: LIBERO-PRO demo starts placed runtime module +- **WHEN** a developer runs the LIBERO-PRO demo with a compatible prepared Python project runtime environment and prepared registered-suite assets +- **THEN** the demo uses the package-local blueprint helper, obtains runtime metadata, resolves the runtime plan, starts the DimOS control path, runs the configured step loop, collects artifacts, and tears down the runtime + +#### Scenario: LIBERO-PRO demo exercises motor command and state flow +- **WHEN** the LIBERO-PRO demo sends scripted Panda motor position targets through module `step()` +- **THEN** commands flow through the module-native step path and runtime-returned or stream-published motor states flow back into the DimOS side and are recorded in the motor trace + +#### Scenario: LIBERO-PRO camera stream is exported as Image and CameraInfo +- **WHEN** the LIBERO-PRO demo enables a configured camera observation stream +- **THEN** the demo observes at least one `Image` frame and associated `CameraInfo` through DimOS stream outputs rather than through HTTP payload fetching + +#### Scenario: LIBERO-PRO score is recorded +- **WHEN** the LIBERO-PRO demo completes, times out, or reaches done +- **THEN** the demo requests module-owned score output and writes success, reward or score, step count, task metadata, runtime trace summary, motor trace, and logs to the artifact directory + +#### Scenario: LIBERO-PRO demo does not require agent task success +- **WHEN** the scripted LIBERO-PRO demo does not solve the selected task successfully +- **THEN** the demo can still pass if module control flow, motor flow, observation flow, score collection, and teardown satisfy the demo acceptance checks diff --git a/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/simulator-runtime-modules/spec.md b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/simulator-runtime-modules/spec.md new file mode 100644 index 0000000000..fc81178971 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/specs/simulator-runtime-modules/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Simulator Runtime Module boundary +The system SHALL provide import-safe Simulator Runtime Modules that expose benchmark simulator runtimes as first-class DimOS Modules while keeping simulator-heavy imports inside the placed worker runtime environment. + +#### Scenario: Coordinator imports module without simulator dependencies +- **WHEN** the main DimOS environment imports a simulator runtime module class or package-local blueprint helper +- **THEN** the import succeeds without importing Robosuite, LIBERO-PRO, Torch, OmniGibson, or backend asset libraries + +#### Scenario: Worker owns simulator backend +- **WHEN** a placed simulator runtime module starts in its named runtime environment +- **THEN** backend-specific simulator imports, environment construction, reset, stepping, rendering, and scoring occur inside that worker environment + +### Requirement: Simulator runtime blueprint helper +Each simulator runtime package SHALL provide a package-local blueprint helper that registers a named Python project runtime environment and places its Simulator Runtime Module using the standard blueprint-level runtime placement API. + +#### Scenario: Default helper places simulator module +- **WHEN** a caller uses the package-local simulator runtime blueprint helper without custom placement arguments +- **THEN** the returned blueprint registers a default `PythonProjectRuntimeEnvironment` and maps the simulator runtime module class to that environment name + +#### Scenario: Caller overrides runtime environment +- **WHEN** a caller supplies a compatible runtime environment override to the helper +- **THEN** the helper uses the supplied environment name for module placement without requiring module-local deployment flags + +### Requirement: Simulator runtime control RPCs +Simulator Runtime Modules SHALL expose module RPCs for runtime description, episode reset, synchronous step, and score collection. + +#### Scenario: Reset establishes episode state +- **WHEN** a benchmark runner calls `reset()` on a Simulator Runtime Module with a valid episode request +- **THEN** the module establishes the new episode state before simulation time advances and returns reset metadata synchronously + +#### Scenario: Missing setup fails through reset +- **WHEN** required simulator assets, task files, or backend configuration are missing for the requested episode +- **THEN** `reset()` fails synchronously with an actionable error rather than relying on event-topic timeout behavior + +#### Scenario: Step blocks until benchmark tick completes +- **WHEN** a benchmark runner calls `step()` with a valid runtime motor action frame +- **THEN** the call returns only after the simulator has applied the action and completed the corresponding benchmark tick + +### Requirement: Simulator runtime owner thread +Simulator Runtime Modules MUST execute backend reset, step, render, and observation capture on a simulator owner thread or main-thread runtime loop, not directly from DimOS RPC or stream callback threads. + +#### Scenario: RPC handler marshals simulator mutation +- **WHEN** a DimOS RPC handler receives a reset or step request +- **THEN** it marshals the backend mutation to the simulator owner thread and waits for the owner-thread result + +#### Scenario: Backend requires main thread +- **WHEN** a visual simulator backend requires process main-thread ownership for rendering or event handling +- **THEN** the runtime module uses a main-thread worker mode or equivalent owner-loop pattern that preserves backend thread affinity + +### Requirement: Runtime motor action frame input +Simulator Runtime Module `step()` SHALL accept the runtime-derived ordered motor action frame for the simulator's declared whole-body motor surface rather than backend-native opaque action vectors. + +#### Scenario: Ordered motor action is validated +- **WHEN** `step()` receives a motor action frame +- **THEN** the module validates robot id, command mode, ordered motor names, and field lengths against the runtime description before applying the action + +#### Scenario: Backend action vector is internal +- **WHEN** the simulator backend requires an action vector or backend-specific command object +- **THEN** the module translates the validated runtime motor action frame internally without exposing backend-native action vectors at the DimOS boundary + +### Requirement: Simulator runtime data streams +Simulator Runtime Modules SHALL publish large and continuous runtime data through DimOS-native typed streams rather than embedding large payloads or raw NumPy arrays in RPC responses. + +#### Scenario: Camera observation publishes as Image and CameraInfo streams +- **WHEN** a simulator step produces a configured camera observation +- **THEN** the module publishes image data as `dimos.msgs.sensor_msgs.Image` and camera metadata as `CameraInfo` through typed DimOS output streams using the blueprint-selected transport + +#### Scenario: Depth observation avoids lossy image compression by default +- **WHEN** a simulator step produces a depth observation +- **THEN** the module publishes depth as an `Image` with a depth-compatible format and uses a raw typed transport unless the blueprint explicitly selects an acceptable compression strategy + +#### Scenario: Step response stays lightweight +- **WHEN** `step()` completes after producing observations +- **THEN** the RPC response contains control/evaluation metadata and observation sequence or timestamp references, not image/depth tensor payloads + +#### Scenario: Raw NumPy array stays internal +- **WHEN** simulator backend code produces an image as a NumPy array +- **THEN** the array is wrapped in a DimOS `Image` message before crossing the module stream boundary + +### Requirement: HTTP runtime removal gate +The system SHALL treat removal of existing HTTP runtime sidecar servers, clients, payload fetch endpoints, and HTTP-first demos as a success gate for the Simulator Runtime Module migration. + +#### Scenario: Migration is incomplete while HTTP runtime boundary remains +- **WHEN** a migrated simulator runtime package still requires an HTTP server, HTTP client, HTTP payload endpoint, or HTTP-first demo path for benchmark execution +- **THEN** the migration is not considered complete for that runtime + +#### Scenario: HTTP is removed when module coverage lands +- **WHEN** fake, Robosuite, and selected LIBERO-PRO module-native paths cover import boundaries, runtime preparation, control RPCs, data streams, benchmark parity, and active consumers no longer require HTTP +- **THEN** the system removes HTTP runtime entrypoints, HTTP payload fetch APIs, HTTP client code, and HTTP-first runtime demo paths diff --git a/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/tasks.md b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/tasks.md new file mode 100644 index 0000000000..4fc4912b92 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-migrate-sim-sidecars-to-runtime-modules/tasks.md @@ -0,0 +1,48 @@ +## 1. Shared Simulator Runtime Module Foundations + +- [x] 1.1 Define import-safe shared runtime module interfaces, request/response type aliases, and stream naming conventions for `describe`, `reset`, `step`, `score`, motor state, `Image` observations, `CameraInfo`, and runtime events. +- [x] 1.2 Add a simulator owner-thread/executor utility or pattern for marshalling reset, step, render, and publish work from RPC handlers to the simulator owner thread. +- [x] 1.3 Add contract tests proving coordinator imports of simulator runtime module classes and blueprint helpers do not import simulator-heavy dependencies. +- [x] 1.4 Add contract tests proving `step()` responses remain lightweight and large observations are emitted as DimOS `Image`/`CameraInfo` streams rather than raw NumPy arrays or RPC payloads. + +## 2. Fake Runtime Module Migration + +- [x] 2.1 Add `FakeRuntimeModule` in the fake runtime package by wrapping existing fake runtime state behind DimOS RPCs and typed stream outputs. +- [x] 2.2 Add a package-local fake runtime blueprint helper that registers the runtime environment, places only `FakeRuntimeModule`, and allows environment override. +- [x] 2.3 Migrate the fake runtime smoke demo to use the module-native path instead of HTTP client/payload fetch plumbing. +- [x] 2.4 Add tests for fake runtime placement, reset, synchronous step, score, motor state publication, and observation/event stream publication. + +## 3. Robosuite Runtime Module Migration + +- [x] 3.1 Add import-safe `RobosuiteRuntimeModule` and package-local blueprint helper in `packages/dimos-robosuite-sidecar`. +- [x] 3.2 Wrap existing `RobosuiteRuntimeState` logic for describe, reset, step, motor surface validation, scoring, and observation generation. +- [x] 3.3 Ensure all Robosuite `env.reset`, `env.step`, render, and camera capture operations run through the simulator owner-thread path. +- [x] 3.4 Publish Robosuite camera images as `Image`, camera metadata as `CameraInfo`, motor states, and runtime events through typed DimOS output streams. +- [x] 3.5 Migrate the Robosuite Panda Lift demo to use the package-local blueprint helper, runtime placement/preparation guidance, module RPCs, and DimOS streams. +- [x] 3.6 Add Robosuite contract tests for import boundaries, runtime placement, action validation, owner-thread marshalling, stream publication, score shape, and HTTP server removal gates. + +## 4. LIBERO-PRO Runtime Module Migration + +- [x] 4.1 Add import-safe `LiberoProRuntimeModule` and package-local blueprint helper in `packages/dimos-libero-pro-sidecar`. +- [x] 4.2 Wrap existing LIBERO-PRO backend/runtime state for registered task selection, asset validation, reset, step, motor surface validation, scoring, and observation generation. +- [x] 4.3 Make missing LIBERO-PRO assets fail synchronously through module reset/setup validation with actionable errors. +- [x] 4.4 Ensure all LIBERO-PRO reset, init-state application, step, render, and camera capture operations run through the simulator owner-thread path. +- [x] 4.5 Publish LIBERO-PRO camera images as `Image`, camera metadata as `CameraInfo`, motor states, and runtime events through typed DimOS output streams. +- [x] 4.6 Migrate the LIBERO-PRO demo to use the package-local blueprint helper, explicit asset preparation, runtime placement/preparation guidance, module RPCs, and DimOS streams. +- [x] 4.7 Add always-on LIBERO-PRO contract tests with stubs and optional/manual real integration coverage for the placed module path. + +## 5. Demo, Documentation, and Compatibility Cleanup + +- [x] 5.1 Update runtime sidecar/runtime environment docs to describe Simulator Runtime Modules as the target architecture and HTTP runtime API removal as a migration success gate. +- [x] 5.2 Update runtime scripted demo specs/docs to remove HTTP-first acceptance criteria after module-native demos pass. +- [x] 5.3 Remove script-local Rerun publishers and HTTP payload-fetch paths from migrated demos once equivalent DimOS streams are verified. +- [x] 5.4 Audit all references to `RuntimeSidecarClient`, `/payloads`, HTTP sidecar launch commands, and `shm_motor.py` to classify each as migrated, removed, or non-runtime benchmark usage. +- [x] 5.5 Delete HTTP runtime server entrypoints, HTTP client code, HTTP payload endpoint tests, and HTTP-first runtime docs/spec language after module-native coverage lands. + +## 6. Verification + +- [x] 6.1 Run focused unit and contract tests for runtime environments, venv/module placement, fake runtime module, Robosuite runtime module, and LIBERO-PRO runtime module. +- [x] 6.2 Run the fake module-native smoke demo in the normal DimOS environment. +- [x] 6.3 Run the Robosuite Panda Lift module-native demo in a prepared Robosuite runtime environment. +- [x] 6.4 Document and gate the optional/manual LIBERO-PRO module-native integration on prepared dependencies and assets; always-on stub coverage remains the archive gate in environments without LIBERO assets. +- [x] 6.5 Run `openspec status --change "migrate-sim-sidecars-to-runtime-modules"` and confirm all required artifacts remain complete. diff --git a/openspec/specs/runtime-libero-pro-sidecar/spec.md b/openspec/specs/runtime-libero-pro-sidecar/spec.md index 02dd195b92..ca789baa03 100644 --- a/openspec/specs/runtime-libero-pro-sidecar/spec.md +++ b/openspec/specs/runtime-libero-pro-sidecar/spec.md @@ -1,22 +1,26 @@ ## Purpose -Define the LIBERO-PRO runtime sidecar package, registered-suite task selection, asset preparation boundary, motor-control contract, observation export, scoring, and verification split for LIBERO-PRO benchmark demos. +Define the LIBERO-PRO runtime package, registered-suite task selection, asset preparation boundary, motor-control contract, observation export, scoring, and verification split for LIBERO-PRO benchmark demos through a Simulator Runtime Module. ## Requirements ### Requirement: LIBERO-PRO sidecar package -The system SHALL provide a first-class LIBERO-PRO runtime sidecar package in the monorepo that depends on the runtime protocol package and isolates LIBERO-PRO-specific dependencies from the main DimOS package. +The system SHALL evolve the first-class LIBERO-PRO runtime package into an import-safe package that provides a Simulator Runtime Module for LIBERO-PRO execution and removes the existing HTTP server boundary as part of migration success. -#### Scenario: LIBERO-PRO sidecar imports without DimOS -- **WHEN** a developer imports the LIBERO-PRO sidecar package module in a normal DimOS development environment without LIBERO-PRO installed -- **THEN** the import succeeds without importing `dimos`, `libero`, `robosuite`, or `torch` +#### Scenario: LIBERO-PRO module imports without backend dependencies +- **WHEN** a developer imports the LIBERO-PRO runtime module class or blueprint helper in a normal DimOS development environment without LIBERO-PRO installed +- **THEN** the import succeeds without importing `libero`, `robosuite`, `torch`, or backend asset libraries -#### Scenario: LIBERO-PRO sidecar installs in isolated environment -- **WHEN** a developer installs the LIBERO-PRO sidecar package in a LIBERO-PRO-compatible environment -- **THEN** the sidecar can start and import runtime protocol models without installing the main DimOS package +#### Scenario: LIBERO-PRO runtime installs in isolated environment +- **WHEN** a developer prepares the LIBERO-PRO runtime package in a LIBERO-PRO-compatible Python project runtime environment +- **THEN** the placed DimOS worker can import DimOS worker runtime support, runtime protocol models, LIBERO-PRO, and required backend dependencies + +#### Scenario: HTTP entrypoint is removed +- **WHEN** the LIBERO-PRO Simulator Runtime Module covers runtime description, reset, step, observation streams, score, and demo execution +- **THEN** the package no longer exposes an HTTP runtime server entrypoint for benchmark execution ### Requirement: Registered LIBERO-PRO task selection -The LIBERO-PRO sidecar SHALL support registered LIBERO-PRO benchmark suites in v1 using backend options for benchmark name, task order index, task index, init-state index, controller, cameras, horizon, and asset roots. +The LIBERO-PRO runtime package SHALL support registered LIBERO-PRO benchmark suites in v1 using backend options for benchmark name, task order index, task index, init-state index, controller, cameras, horizon, and asset roots. #### Scenario: Registered task is described - **WHEN** the sidecar is configured with a registered LIBERO-PRO benchmark name, task order index, task index, and init-state index @@ -27,22 +31,22 @@ The LIBERO-PRO sidecar SHALL support registered LIBERO-PRO benchmark suites in v - **THEN** the sidecar rejects the setup with a clear error before starting an episode ### Requirement: LIBERO-PRO asset validation and bootstrap -The system SHALL support explicit opt-in LIBERO-PRO runtime asset bootstrap while requiring sidecar startup and health checks to validate prepared assets without downloading or mutating local asset layout. +The system SHALL support explicit opt-in LIBERO-PRO runtime asset bootstrap while requiring module reset/setup validation to report prepared asset failures without downloading or mutating local asset layout by default. #### Scenario: Prepared assets validate successfully - **WHEN** required BDDL and init-state assets exist for the selected registered suite task -- **THEN** the sidecar health or setup validation reports the assets as usable without modifying them +- **THEN** module reset or setup validation reports the assets as usable without modifying them #### Scenario: Missing assets fail clearly - **WHEN** required BDDL or init-state assets are missing for the selected registered suite task -- **THEN** the sidecar reports a clear validation failure that identifies the missing asset category before episode reset +- **THEN** module `reset()` fails synchronously with a clear validation error that identifies the missing asset category #### Scenario: Asset bootstrap is explicit - **WHEN** a developer requests asset preparation through an explicit bootstrap command or demo flag -- **THEN** the system may retrieve and stage supported external assets and then validates the resulting layout before sidecar use +- **THEN** the system may retrieve and stage supported external assets and then validates the resulting layout before module reset uses the assets ### Requirement: LIBERO-PRO motor surface validation -The LIBERO-PRO sidecar SHALL expose the full-control v1 path only when the selected task and controller provide a Panda joint-position plus gripper whole-body motor surface compatible with DimOS motor action frames. +The LIBERO-PRO runtime package SHALL expose the full-control v1 path only when the selected task and controller provide a Panda joint-position plus gripper whole-body motor surface compatible with DimOS motor action frames. #### Scenario: Compatible motor surface is described - **WHEN** the selected LIBERO-PRO environment exposes the expected Panda joint-position plus gripper action surface @@ -50,37 +54,37 @@ The LIBERO-PRO sidecar SHALL expose the full-control v1 path only when the selec #### Scenario: Incompatible controller fails fast - **WHEN** the selected LIBERO-PRO environment exposes only OSC pose control or an action dimension that cannot be mapped to Panda joint-position plus gripper commands -- **THEN** the sidecar rejects the episode setup with a clear protocol error before accepting step requests +- **THEN** the runtime module rejects the episode setup with a clear protocol error before accepting step requests ### Requirement: LIBERO-PRO step ownership and observation export -The LIBERO-PRO sidecar SHALL own backend-native environment reset and step calls and SHALL translate runtime protocol action frames into LIBERO-PRO actions while exporting motor state, reward, done, success, and observation metadata. +The LIBERO-PRO runtime package SHALL own backend-native environment reset and step calls inside its Simulator Runtime Module and SHALL translate runtime motor action frames into LIBERO-PRO actions while exporting motor state, reward, done, success, and observations through module RPC metadata and DimOS-native typed streams. -#### Scenario: LIBERO-PRO reset applies init state -- **WHEN** DimOS requests episode reset for a configured LIBERO-PRO registered task -- **THEN** the sidecar resets the environment, applies the selected init state, and returns initial motor state and task metadata +#### Scenario: LIBERO-PRO reset applies init state through RPC +- **WHEN** DimOS calls module `reset()` for a configured LIBERO-PRO registered task +- **THEN** the module resets the environment, applies the selected init state on the simulator owner thread, and returns initial task and motor metadata -#### Scenario: Motor step advances LIBERO-PRO -- **WHEN** DimOS sends a motor position action frame for the described Panda motor surface -- **THEN** the sidecar maps the action to the LIBERO-PRO environment step and returns motor state, reward, done, success if available, and observation frames +#### Scenario: Motor step advances LIBERO-PRO through RPC +- **WHEN** DimOS calls module `step()` with a motor position action frame for the described Panda motor surface +- **THEN** the module maps the action to the LIBERO-PRO environment step on the simulator owner thread and returns reward, done, success if available, and lightweight step metadata -#### Scenario: Camera payload can be fetched -- **WHEN** a LIBERO-PRO step response includes a camera observation with a payload reference -- **THEN** the DimOS runtime client can fetch the referenced `.npy` payload for stream publication and artifacts +#### Scenario: Camera observation publishes as Image and CameraInfo streams +- **WHEN** a LIBERO-PRO step produces a configured camera observation +- **THEN** the module publishes the camera output as `Image` and camera metadata as `CameraInfo` through DimOS streams without requiring DimOS to fetch a `.npy` payload from an HTTP endpoint or receive a raw NumPy array in the step response ### Requirement: Sidecar-owned LIBERO-PRO score -The LIBERO-PRO sidecar SHALL provide normalized episode score output that includes backend-owned success extraction, reward or score, step count, and task metadata. +The LIBERO-PRO runtime package SHALL provide normalized episode score output that includes backend-owned success extraction, reward or score, step count, and task metadata. #### Scenario: Score is collected after episode - **WHEN** the LIBERO-PRO demo completes, times out, or reaches done -- **THEN** the runner can request score output from the sidecar and write success, reward or score, steps, benchmark name, task name, language, and init-state index with episode artifacts +- **THEN** the runner can request score output from the runtime module and write success, reward or score, steps, benchmark name, task name, language, and init-state index with episode artifacts ### Requirement: LIBERO-PRO verification split -The system SHALL verify LIBERO-PRO sidecar behavior with always-on contract tests that do not require real LIBERO-PRO dependencies or data, and SHALL keep real LIBERO-PRO execution behind optional/manual integration coverage. +The system SHALL verify LIBERO-PRO module-runtime behavior with always-on contract tests that do not require real LIBERO-PRO dependencies or data, and SHALL keep real LIBERO-PRO execution behind optional/manual integration coverage. #### Scenario: Normal CI runs without LIBERO-PRO data - **WHEN** normal test suites run in the main DimOS development environment -- **THEN** they verify import boundaries, backend option validation, stubbed sidecar endpoints, action-surface failures, and score shape without requiring LIBERO-PRO assets or dependencies +- **THEN** they verify import boundaries, blueprint helper placement, runtime environment selection, stubbed module RPCs, action-surface failures, and score shape without requiring LIBERO-PRO assets or dependencies #### Scenario: Manual integration exercises real LIBERO-PRO - **WHEN** a developer runs the optional real LIBERO-PRO integration with prepared dependencies and assets -- **THEN** it launches the real sidecar, runs the full ControlCoordinator and SHM demo path for one registered task, fetches camera payloads, and writes score and artifacts +- **THEN** it launches the placed Simulator Runtime Module, runs reset and synchronous step RPCs for one registered task, observes camera stream publication, and writes score and artifacts diff --git a/openspec/specs/runtime-protocol/spec.md b/openspec/specs/runtime-protocol/spec.md index a1666206c9..85106dd58a 100644 --- a/openspec/specs/runtime-protocol/spec.md +++ b/openspec/specs/runtime-protocol/spec.md @@ -1,19 +1,19 @@ ## Purpose -Define the lightweight backend-neutral protocol contract shared by DimOS runtime clients and simulator sidecars. +Define the lightweight backend-neutral protocol contract shared by DimOS and simulator runtime packages. ## Requirements ### Requirement: Shared runtime protocol package -The system SHALL provide a lightweight installable runtime protocol package that can be used by DimOS and simulator sidecars without installing the main DimOS package or any simulator backend SDK. +The system SHALL provide lightweight backend-neutral runtime protocol models that can be reused by DimOS and simulator runtime packages without requiring HTTP transport, the main DimOS package in schema-only contexts, or any simulator backend SDK. -#### Scenario: Sidecar installs protocol without DimOS -- **WHEN** a Robosuite sidecar environment installs the runtime protocol package -- **THEN** it can import the protocol models and codecs without importing `dimos`, Robosuite-incompatible DimOS dependencies, or any DimOS hardware adapter modules +#### Scenario: Runtime package imports protocol without simulator backend +- **WHEN** a simulator runtime package imports protocol models for validation or compatibility logic +- **THEN** it can import those models without importing Robosuite, LIBERO-PRO, OmniGibson, or backend-specific dependencies -#### Scenario: DimOS imports the same protocol package -- **WHEN** the DimOS runtime client imports protocol models -- **THEN** it uses the same package and protocol version as the sidecar compatibility handshake +#### Scenario: DimOS module boundary reuses protocol models +- **WHEN** a Simulator Runtime Module exposes RPC request or response payloads based on runtime protocol models +- **THEN** the models describe backend-neutral runtime semantics without implying HTTP JSON transport or payload-fetch endpoints ### Requirement: Protocol model validation The protocol package SHALL define Pydantic models for runtime description, episode reset, step requests, step responses, robot motor surfaces, motor action frames, motor state frames, observation frames, scores, artifacts, and errors. @@ -27,26 +27,30 @@ The protocol package SHALL define Pydantic models for runtime description, episo - **THEN** the response includes robot id, surface type, ordered motors, supported command modes, and available state fields ### Requirement: Protocol compatibility handshake -The runtime protocol SHALL include protocol version and capability metadata in the sidecar handshake so DimOS can fail fast on incompatible protocol versions or unsupported capabilities. +The runtime protocol SHALL include protocol version and capability metadata that simulator runtimes can report through module-native description RPCs or compatibility handshakes so DimOS can fail fast on incompatible protocol versions or unsupported capabilities. -#### Scenario: Compatible sidecar connects -- **WHEN** DimOS connects to a sidecar using a compatible protocol version -- **THEN** the runtime client accepts the sidecar description and records the protocol version in artifacts +#### Scenario: Compatible module runtime describes itself +- **WHEN** DimOS calls `describe()` on a Simulator Runtime Module using a compatible protocol version +- **THEN** the runtime description includes protocol version and capability metadata that DimOS records in artifacts -#### Scenario: Incompatible sidecar connects -- **WHEN** DimOS connects to a sidecar using an incompatible protocol version -- **THEN** prelaunch fails before launching the DimOS blueprint and records the incompatibility reason +#### Scenario: Incompatible runtime is rejected +- **WHEN** DimOS describes a simulator runtime using an incompatible protocol version or unsupported capabilities +- **THEN** prelaunch or runtime setup fails before benchmark stepping and records the incompatibility reason ### Requirement: Binary-friendly observation transport -The runtime protocol SHALL support image, depth, segmentation, and object/state observations without requiring large image tensors to be encoded as nested JSON lists. +The runtime protocol SHALL support image, depth, segmentation, and object/state observation metadata without requiring large image tensors, raw NumPy arrays, or nested JSON lists to be encoded in RPC responses. -#### Scenario: Image observation uses reference or binary payload -- **WHEN** a sidecar returns an RGB image observation -- **THEN** the observation frame includes stream name, kind, encoding, shape, dtype, and either a binary payload reference or a supported binary payload representation +#### Scenario: Image observation uses DimOS stream metadata +- **WHEN** a Simulator Runtime Module publishes an RGB image observation on a DimOS stream +- **THEN** associated observation metadata identifies the `Image` stream, encoding, shape, dtype, sequence, or timestamp sufficient for consumers and artifacts to correlate the stream output -#### Scenario: Referenced array payload is fetched separately -- **WHEN** an observation frame reports a raw array payload reference -- **THEN** the DimOS runtime client can fetch the referenced `.npy` bytes without embedding image arrays in the JSON step response +#### Scenario: Camera model uses CameraInfo stream +- **WHEN** a Simulator Runtime Module publishes an image stream with camera intrinsics +- **THEN** the camera model is published as `CameraInfo` rather than embedded as ad hoc metadata in the step response + +#### Scenario: HTTP payload reference is removed from target path +- **WHEN** a runtime observation is produced by a migrated Simulator Runtime Module +- **THEN** the target path uses stream metadata and DimOS stream outputs rather than HTTP payload references ### Requirement: Backend-neutral protocol types Runtime protocol models MUST NOT expose Robosuite, LIBERO-PRO, OmniGibson, DimOS hardware adapter, or simulator object types in public fields. diff --git a/openspec/specs/runtime-robosuite-sidecar/spec.md b/openspec/specs/runtime-robosuite-sidecar/spec.md index df89cf24aa..e3c6b5b278 100644 --- a/openspec/specs/runtime-robosuite-sidecar/spec.md +++ b/openspec/specs/runtime-robosuite-sidecar/spec.md @@ -1,25 +1,33 @@ ## Purpose -Define the Robosuite runtime sidecar boundary for baked Robosuite task execution, motor-surface mapping, observation export, and score/artifact reporting. +Define the Robosuite runtime package boundary for baked Robosuite task execution, motor-surface mapping, observation export, and score/artifact reporting through a Simulator Runtime Module. ## Requirements ### Requirement: Robosuite sidecar package -The system SHALL provide a first-class Robosuite sidecar package in the monorepo that depends on the runtime protocol package and Robosuite-specific dependencies without depending on the main DimOS package. +The system SHALL evolve the first-class Robosuite runtime package into an import-safe package that provides a Simulator Runtime Module for Robosuite execution and removes the existing HTTP server boundary as part of migration success. -#### Scenario: Robosuite sidecar installs in isolated environment -- **WHEN** a developer installs the Robosuite sidecar package in a Robosuite-compatible environment -- **THEN** the sidecar can start and import runtime protocol models without installing the main DimOS package +#### Scenario: Robosuite package imports in main DimOS environment +- **WHEN** a developer imports the Robosuite runtime module class or blueprint helper in the main DimOS environment +- **THEN** the import succeeds without importing Robosuite, MuJoCo renderer state, or backend-only dependencies + +#### Scenario: Robosuite runtime installs in isolated environment +- **WHEN** a developer prepares the Robosuite runtime package in a Robosuite-compatible Python project runtime environment +- **THEN** the placed DimOS worker can import DimOS worker runtime support, runtime protocol models, and Robosuite backend dependencies + +#### Scenario: HTTP entrypoint is removed +- **WHEN** the Robosuite Simulator Runtime Module covers runtime description, reset, step, observation streams, score, and demo execution +- **THEN** the package no longer exposes an HTTP runtime server entrypoint for benchmark execution ### Requirement: Baked Robosuite task instantiation -The Robosuite sidecar SHALL instantiate baked Robosuite tasks from episode config fields such as env name, robot name, controller profile, control frequency, horizon, renderer options, camera options, and seed. +The Robosuite runtime package SHALL instantiate baked Robosuite tasks from episode config fields such as env name, robot name, controller profile, control frequency, horizon, renderer options, camera options, and seed. #### Scenario: Panda Lift task starts - **WHEN** the episode config requests `env_name: Lift` and `robots: Panda` -- **THEN** the sidecar creates the corresponding Robosuite environment and exposes its runtime description +- **THEN** the runtime module creates the corresponding Robosuite environment and exposes its runtime description ### Requirement: Runtime-derived motor surface -The Robosuite sidecar SHALL derive robot motor surface metadata from the live Robosuite environment and controller setup rather than requiring every benchmark config to manually enumerate Robosuite action indices. +The Robosuite runtime package SHALL derive robot motor surface metadata from the live Robosuite environment and controller setup rather than requiring every benchmark config to manually enumerate Robosuite action indices. #### Scenario: Panda motor order is described - **WHEN** the sidecar creates a Panda Lift environment @@ -27,25 +35,33 @@ The Robosuite sidecar SHALL derive robot motor surface metadata from the live Ro #### Scenario: Unsupported controller profile fails - **WHEN** the selected Robosuite controller profile cannot be mapped to a supported DimOS motor command mode -- **THEN** the sidecar rejects the episode setup with a protocol error that identifies the unsupported profile +- **THEN** the runtime module rejects the episode setup with a protocol error that identifies the unsupported profile ### Requirement: Robosuite step ownership -The Robosuite sidecar SHALL own backend-native `env.reset()` and `env.step(action)` calls and SHALL translate between runtime protocol action/state frames and Robosuite action/observation structures. +The Robosuite runtime package SHALL own backend-native `env.reset()` and `env.step(action)` calls inside its Simulator Runtime Module and SHALL translate between runtime motor action frames, runtime state metadata, and Robosuite action/observation structures. -#### Scenario: Motor position step advances Robosuite -- **WHEN** DimOS sends a motor position action frame for the described Panda motor surface -- **THEN** the sidecar maps it to the Robosuite action vector, steps the environment, and returns motor state, reward, done, success if available, and observation metadata +#### Scenario: Motor position step advances Robosuite through RPC +- **WHEN** DimOS calls module `step()` with a motor position action frame for the described Panda motor surface +- **THEN** the module maps it to the Robosuite action vector, steps the environment on the simulator owner thread, and returns reward, done, success if available, and lightweight step metadata + +#### Scenario: Robosuite APIs stay on owner thread +- **WHEN** the module handles reset, step, render, or camera capture +- **THEN** those backend operations run on the simulator owner thread rather than directly on an RPC or stream callback thread ### Requirement: Observation export -The Robosuite sidecar SHALL expose configured Robosuite camera and state observations through runtime protocol observation frames that DimOS can publish as observation streams. +The Robosuite runtime package SHALL expose configured Robosuite camera and state observations through DimOS-native typed streams from its Simulator Runtime Module, with RPC responses containing only lightweight observation metadata. + +#### Scenario: Agentview camera is published as Image and CameraInfo streams +- **WHEN** the episode config enables the `agentview` camera and a step produces a frame +- **THEN** the module publishes the camera output as `Image` and camera metadata as `CameraInfo` through DimOS streams without requiring DimOS to fetch a `.npy` payload from an HTTP endpoint or receive a raw NumPy array in the step response -#### Scenario: Agentview camera is available -- **WHEN** the episode config enables the `agentview` camera -- **THEN** step responses include observation frames with `.npy` payload references that allow DimOS to fetch and publish the camera output as a stream +#### Scenario: Step response references published observation +- **WHEN** a step produces an observation frame +- **THEN** the step response includes sequence, timestamp, or stream metadata sufficient to correlate the step with stream output without embedding image tensors ### Requirement: Score and artifact export -The Robosuite sidecar SHALL provide score and artifact outputs for each episode, including reward/done/success metadata, backend timing, and sidecar logs or trace summaries. +The Robosuite runtime package SHALL provide score and artifact outputs for each episode, including reward/done/success metadata, backend timing, and runtime logs or trace summaries. #### Scenario: Score is collected after demo - **WHEN** the demo completes or times out -- **THEN** the runner can request score output from the sidecar and write it with the episode artifacts +- **THEN** the runner can request score output from the runtime module and write it with the episode artifacts diff --git a/openspec/specs/runtime-scripted-demos/spec.md b/openspec/specs/runtime-scripted-demos/spec.md index bed923c691..f258585a10 100644 --- a/openspec/specs/runtime-scripted-demos/spec.md +++ b/openspec/specs/runtime-scripted-demos/spec.md @@ -1,65 +1,65 @@ ## Purpose -Define script-based runtime sidecar demos that validate protocol, motor-control, observation, visualization, artifact, and teardown plumbing without adding a new DimOS CLI command or requiring agent task success. +Define script-based simulator runtime demos that validate module-native control, motor-state, observation, visualization, artifact, and teardown plumbing without adding a new DimOS CLI command or requiring agent task success. ## Requirements -### Requirement: Fake sidecar smoke demo -The system SHALL include a script-based fake sidecar smoke demo that validates protocol handshake, prelaunch orchestration, resolved runtime plan generation, local motor bridge behavior, ControlCoordinator integration, and artifact output without requiring Robosuite. +### Requirement: Fake simulator runtime smoke demo +The system SHALL include a script-based fake simulator runtime smoke demo that validates module-native control behavior, resolved runtime plan generation, typed DimOS stream publication, and artifact output without requiring Robosuite. #### Scenario: Fake demo completes in normal DimOS environment -- **WHEN** a developer runs the fake sidecar demo script in the normal DimOS development environment -- **THEN** the demo completes a configured number of ticks and writes episode config, resolved plan, protocol trace summary, motor trace, score output, and logs +- **WHEN** a developer runs the fake simulator runtime demo script in the normal DimOS development environment +- **THEN** the demo completes a configured number of synchronous module steps and writes episode config, resolved plan, module trace summary, motor trace, score output, and logs #### Scenario: Fake demo exercises motor command/state flow - **WHEN** the fake demo runs a scripted motor command sequence -- **THEN** commands flow from the ControlCoordinator-facing surface through the local bridge and protocol client, and motor states flow back into the DimOS side +- **THEN** commands flow through the module-native step path and motor states flow back into the DimOS side through the accepted module/stream contract ### Requirement: Robosuite Panda Lift plumbing demo -The system SHALL include a script-based Robosuite Panda Lift plumbing demo that validates the real Robosuite sidecar, runtime description, derived motor mapping, network protocol, local motor bridge, observation stream export, score collection, and artifact output. +The system SHALL include a script-based Robosuite Panda Lift plumbing demo that validates the placed Robosuite Simulator Runtime Module, runtime description, derived motor mapping, synchronous step RPC, DimOS observation streams, score collection, and artifact output. -#### Scenario: Robosuite demo starts sidecar and DimOS blueprint -- **WHEN** a developer runs the Robosuite Panda Lift demo script with a compatible Robosuite sidecar environment -- **THEN** the script starts the sidecar, derives the resolved runtime plan from live sidecar metadata, starts the DimOS blueprint, runs the scripted sequence, collects artifacts, and tears down both runtimes +#### Scenario: Robosuite demo starts placed runtime module +- **WHEN** a developer runs the Robosuite Panda Lift demo script with a compatible prepared Robosuite Python project runtime environment +- **THEN** the script uses the package-local blueprint helper, starts the placed Simulator Runtime Module, obtains runtime metadata, runs the scripted sequence, collects artifacts, and tears down the runtime #### Scenario: Robosuite joint state changes from scripted command -- **WHEN** the Robosuite demo sends a scripted motor position command sequence -- **THEN** the returned Robosuite-derived motor state changes consistently with the command sequence and is recorded in the motor trace +- **WHEN** the Robosuite demo sends a scripted motor position command sequence through module `step()` +- **THEN** the returned Robosuite-derived motor state metadata or published state stream changes consistently with the command sequence and is recorded in the motor trace -#### Scenario: Robosuite observation stream is exported +#### Scenario: Robosuite observation stream is exported as Image and CameraInfo - **WHEN** the Robosuite demo enables a camera observation stream -- **THEN** DimOS-side artifacts or logs show that at least one observation frame was received from the sidecar and published or recorded by the runtime client +- **THEN** DimOS-side artifacts or logs show that at least one `Image` frame and associated `CameraInfo` were published through the module's DimOS stream outputs #### Scenario: Robosuite camera appears in Rerun through DimOS streams - **WHEN** a developer runs the Robosuite demo with Rerun stream visualization enabled -- **THEN** the demo fetches referenced `.npy` camera payloads, applies the declared image convention, publishes `color_image` and `camera_info` through DimOS streams, and Rerun displays the camera image through its normal stream bridge rather than through direct Rerun SDK logging at the runtime boundary +- **THEN** Rerun displays camera output consumed from normal DimOS streams rather than from direct runtime-boundary Rerun SDK logging or HTTP payload fetching #### Scenario: Rerun demo stream is isolated and bounded - **WHEN** a developer runs the Robosuite demo with Rerun stream visualization enabled repeatedly or alongside other DimOS publishers - **THEN** the demo uses isolated Rerun and local DimOS transport settings for its visualization path and applies a bounded Rerun memory limit so old recordings or unrelated camera topics do not mix with the demo stream ### Requirement: LIBERO-PRO full-control runtime demo -The system SHALL include a script-based LIBERO-PRO runtime demo that validates the real LIBERO-PRO sidecar, runtime description, registered task reset, local SHM motor bridge, ControlCoordinator integration, camera payload export, score collection, artifact output, and teardown. +The system SHALL include a script-based LIBERO-PRO runtime demo that validates the placed LIBERO-PRO Simulator Runtime Module, runtime description, registered task reset, synchronous step RPC, ControlCoordinator integration, camera stream export, score collection, artifact output, and teardown. -#### Scenario: LIBERO-PRO demo starts sidecar and DimOS control path -- **WHEN** a developer runs the LIBERO-PRO demo script with a compatible sidecar environment and prepared registered-suite assets -- **THEN** the script starts the sidecar, obtains runtime metadata, resolves the runtime plan, starts the DimOS control path, runs the configured tick loop, collects artifacts, and tears down both runtimes +#### Scenario: LIBERO-PRO demo starts placed runtime module +- **WHEN** a developer runs the LIBERO-PRO demo script with a compatible prepared Python project runtime environment and prepared registered-suite assets +- **THEN** the script uses the package-local blueprint helper, obtains runtime metadata, resolves the runtime plan, starts the DimOS control path, runs the configured step loop, collects artifacts, and tears down the runtime #### Scenario: LIBERO-PRO demo exercises motor command and state flow -- **WHEN** the LIBERO-PRO demo sends scripted Panda motor position targets through the ControlCoordinator-facing path -- **THEN** commands flow through the local SHM motor bridge to the runtime protocol client, and sidecar-returned motor states flow back into the DimOS side and are recorded in the motor trace +- **WHEN** the LIBERO-PRO demo sends scripted Panda motor position targets through module `step()` +- **THEN** commands flow through the module-native step path and runtime-returned or stream-published motor states flow back into the DimOS side and are recorded in the motor trace -#### Scenario: LIBERO-PRO camera payload is exported +#### Scenario: LIBERO-PRO camera stream is exported as Image and CameraInfo - **WHEN** the LIBERO-PRO demo enables a configured camera observation stream -- **THEN** the demo fetches referenced `.npy` camera payloads and publishes or records at least one camera observation through the runtime observation path +- **THEN** the demo observes at least one `Image` frame and associated `CameraInfo` through DimOS stream outputs rather than through HTTP payload fetching #### Scenario: LIBERO-PRO score is recorded - **WHEN** the LIBERO-PRO demo completes, times out, or reaches done -- **THEN** the demo requests sidecar-owned score output and writes success, reward or score, step count, task metadata, protocol trace summary, motor trace, and logs to the artifact directory +- **THEN** the demo requests module-owned score output and writes success, reward or score, step count, task metadata, runtime trace summary, motor trace, and logs to the artifact directory #### Scenario: LIBERO-PRO demo does not require agent task success - **WHEN** the scripted LIBERO-PRO demo does not solve the selected task successfully -- **THEN** the demo can still pass if protocol, motor flow, observation flow, score collection, and teardown satisfy the demo acceptance checks +- **THEN** the demo can still pass if module control flow, motor flow, observation flow, score collection, and teardown satisfy the demo acceptance checks ### Requirement: LIBERO-PRO asset preparation remains explicit The LIBERO-PRO scripted demo SHALL NOT download or mutate benchmark assets unless the developer passes an explicit asset preparation flag or runs an explicit preparation command. @@ -70,7 +70,7 @@ The LIBERO-PRO scripted demo SHALL NOT download or mutate benchmark assets unles #### Scenario: Demo prepares assets only when requested - **WHEN** a developer runs the LIBERO-PRO demo with an explicit asset preparation option -- **THEN** the demo may run the runtime asset bootstrap before launching the sidecar and still validates the prepared layout before episode reset +- **THEN** the demo may run the runtime asset bootstrap before module reset and still validates the prepared layout before episode stepping ### Requirement: No agent success requirement The scripted demos SHALL verify runtime plumbing and MUST NOT require an LLM, MCP skill policy, or successful task completion by an agent. diff --git a/openspec/specs/simulator-runtime-modules/spec.md b/openspec/specs/simulator-runtime-modules/spec.md new file mode 100644 index 0000000000..18fb1a7f65 --- /dev/null +++ b/openspec/specs/simulator-runtime-modules/spec.md @@ -0,0 +1,94 @@ +## Purpose + +Define the common DimOS Simulator Runtime Module boundary used to expose benchmark simulator runtimes as first-class Modules hosted in named runtime environments. + +## Requirements + +### Requirement: Simulator Runtime Module boundary +The system SHALL provide import-safe Simulator Runtime Modules that expose benchmark simulator runtimes as first-class DimOS Modules while keeping simulator-heavy imports inside the placed worker runtime environment. + +#### Scenario: Coordinator imports module without simulator dependencies +- **WHEN** the main DimOS environment imports a simulator runtime module class or package-local blueprint helper +- **THEN** the import succeeds without importing Robosuite, LIBERO-PRO, Torch, OmniGibson, or backend asset libraries + +#### Scenario: Worker owns simulator backend +- **WHEN** a placed simulator runtime module starts in its named runtime environment +- **THEN** backend-specific simulator imports, environment construction, reset, stepping, rendering, and scoring occur inside that worker environment + +### Requirement: Simulator runtime blueprint helper +Each simulator runtime package SHALL provide a package-local blueprint helper that registers a named Python project runtime environment and places its Simulator Runtime Module using the standard blueprint-level runtime placement API. + +#### Scenario: Default helper places simulator module +- **WHEN** a caller uses the package-local simulator runtime blueprint helper without custom placement arguments +- **THEN** the returned blueprint registers a default `PythonProjectRuntimeEnvironment` and maps the simulator runtime module class to that environment name + +#### Scenario: Caller overrides runtime environment +- **WHEN** a caller supplies a compatible runtime environment override to the helper +- **THEN** the helper uses the supplied environment name for module placement without requiring module-local deployment flags + +### Requirement: Simulator runtime control RPCs +Simulator Runtime Modules SHALL expose module RPCs for runtime description, episode reset, synchronous step, and score collection. + +#### Scenario: Reset establishes episode state +- **WHEN** a benchmark runner calls `reset()` on a Simulator Runtime Module with a valid episode request +- **THEN** the module establishes the new episode state before simulation time advances and returns reset metadata synchronously + +#### Scenario: Missing setup fails through reset +- **WHEN** required simulator assets, task files, or backend configuration are missing for the requested episode +- **THEN** `reset()` fails synchronously with an actionable error rather than relying on event-topic timeout behavior + +#### Scenario: Step blocks until benchmark tick completes +- **WHEN** a benchmark runner calls `step()` with a valid runtime motor action frame +- **THEN** the call returns only after the simulator has applied the action and completed the corresponding benchmark tick + +### Requirement: Simulator runtime owner thread +Simulator Runtime Modules MUST execute backend reset, step, render, and observation capture on a simulator owner thread or main-thread runtime loop, not directly from DimOS RPC or stream callback threads. + +#### Scenario: RPC handler marshals simulator mutation +- **WHEN** a DimOS RPC handler receives a reset or step request +- **THEN** it marshals the backend mutation to the simulator owner thread and waits for the owner-thread result + +#### Scenario: Backend requires main thread +- **WHEN** a visual simulator backend requires process main-thread ownership for rendering or event handling +- **THEN** the runtime module uses a main-thread worker mode or equivalent owner-loop pattern that preserves backend thread affinity + +### Requirement: Runtime motor action frame input +Simulator Runtime Module `step()` SHALL accept the runtime-derived ordered motor action frame for the simulator's declared whole-body motor surface rather than backend-native opaque action vectors. + +#### Scenario: Ordered motor action is validated +- **WHEN** `step()` receives a motor action frame +- **THEN** the module validates robot id, command mode, ordered motor names, and field lengths against the runtime description before applying the action + +#### Scenario: Backend action vector is internal +- **WHEN** the simulator backend requires an action vector or backend-specific command object +- **THEN** the module translates the validated runtime motor action frame internally without exposing backend-native action vectors at the DimOS boundary + +### Requirement: Simulator runtime data streams +Simulator Runtime Modules SHALL publish large and continuous runtime data through DimOS-native typed streams rather than embedding large payloads or raw NumPy arrays in RPC responses. + +#### Scenario: Camera observation publishes as Image and CameraInfo streams +- **WHEN** a simulator step produces a configured camera observation +- **THEN** the module publishes image data as `dimos.msgs.sensor_msgs.Image` and camera metadata as `CameraInfo` through typed DimOS output streams using the blueprint-selected transport + +#### Scenario: Depth observation avoids lossy image compression by default +- **WHEN** a simulator step produces a depth observation +- **THEN** the module publishes depth as an `Image` with a depth-compatible format and uses a raw typed transport unless the blueprint explicitly selects an acceptable compression strategy + +#### Scenario: Step response stays lightweight +- **WHEN** `step()` completes after producing observations +- **THEN** the RPC response contains control/evaluation metadata and observation sequence or timestamp references, not image/depth tensor payloads + +#### Scenario: Raw NumPy array stays internal +- **WHEN** simulator backend code produces an image as a NumPy array +- **THEN** the array is wrapped in a DimOS `Image` message before crossing the module stream boundary + +### Requirement: HTTP runtime removal gate +The system SHALL treat removal of existing HTTP runtime sidecar servers, clients, payload fetch endpoints, and HTTP-first demos as a success gate for the Simulator Runtime Module migration. + +#### Scenario: Migration is incomplete while HTTP runtime boundary remains +- **WHEN** a migrated simulator runtime package still requires an HTTP server, HTTP client, HTTP payload endpoint, or HTTP-first demo path for benchmark execution +- **THEN** the migration is not considered complete for that runtime + +#### Scenario: HTTP is removed when module coverage lands +- **WHEN** fake, Robosuite, and selected LIBERO-PRO module-native paths cover import boundaries, runtime preparation, control RPCs, data streams, benchmark parity, and active consumers no longer require HTTP +- **THEN** the system removes HTTP runtime entrypoints, HTTP payload fetch APIs, HTTP client code, and HTTP-first runtime demo paths diff --git a/packages/dimos-fake-runtime-sidecar/pyproject.toml b/packages/dimos-fake-runtime-sidecar/pyproject.toml index c10feb0be3..5604a4a275 100644 --- a/packages/dimos-fake-runtime-sidecar/pyproject.toml +++ b/packages/dimos-fake-runtime-sidecar/pyproject.toml @@ -6,13 +6,15 @@ build-backend = "hatchling.build" name = "dimos-fake-runtime-sidecar" version = "0.1.0" description = "Fake DimOS runtime sidecar for benchmark smoke demos" -requires-python = ">=3.10" +requires-python = ">=3.10,<3.13" dependencies = [ + "dimos", "dimos-runtime-protocol", ] -[project.scripts] -dimos-fake-runtime-sidecar = "dimos_fake_runtime_sidecar.server:main" +[tool.uv.sources] +dimos = { path = "../..", editable = true } +dimos-runtime-protocol = { path = "../dimos-runtime-protocol", editable = true } [tool.hatch.build.targets.wheel] packages = ["src/dimos_fake_runtime_sidecar"] diff --git a/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/blueprint.py b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/blueprint.py new file mode 100644 index 0000000000..e03197c95e --- /dev/null +++ b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/blueprint.py @@ -0,0 +1,46 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Blueprint helpers for the fake simulator runtime module.""" + +from __future__ import annotations + +from pathlib import Path + +from dimos.core.coordination.blueprints import Blueprint, autoconnect +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment +from dimos_fake_runtime_sidecar.module import FakeRuntimeModule + +FAKE_RUNTIME_ENV_NAME = "dimos-fake-runtime" +FAKE_RUNTIME_PROJECT = Path(__file__).resolve().parents[2] + + +def fake_runtime_blueprint( + runtime: PythonProjectRuntimeEnvironment | None = None, + *, + robot_id: str = "fakebot", + dof: int = 3, + step_hz: int = 100, +) -> Blueprint: + """Create a placed fake simulator runtime module blueprint.""" + + environment = runtime or PythonProjectRuntimeEnvironment( + name=FAKE_RUNTIME_ENV_NAME, + project=FAKE_RUNTIME_PROJECT, + ) + return ( + autoconnect(FakeRuntimeModule.blueprint(robot_id=robot_id, dof=dof, step_hz=step_hz)) + .runtime_environments(environment) + .runtime_placements({FakeRuntimeModule: environment.name}) + ) diff --git a/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/module.py b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/module.py new file mode 100644 index 0000000000..d27a7b08a3 --- /dev/null +++ b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/module.py @@ -0,0 +1,151 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""DimOS module wrapper for the fake benchmark runtime.""" + +from __future__ import annotations + +from dimos_runtime_protocol.models import ( + CommandMode, + EpisodeResetRequest, + EpisodeResetResponse, + MotorStateFrame, + ObservationFrame, + ObservationKind, + RuntimeDescription, + ScoreOutput, + StepRequest, + StepResponse, +) +import numpy as np + +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import Out +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.simulation.runtime_module import ( + SimulatorOwnerThread, + module_runtime_description, + publish_output, +) +from dimos_fake_runtime_sidecar.server import FakeRuntimeState + + +class FakeRuntimeModuleConfig(ModuleConfig): + robot_id: str = "fakebot" + dof: int = 3 + step_hz: int = 100 + + +class FakeRuntimeModule(Module): + """First-class DimOS module for the fake runtime protocol backend.""" + + config: FakeRuntimeModuleConfig + + motor_state: Out[MotorStateFrame] + color_image: Out[Image] + camera_info: Out[CameraInfo] + runtime_event: Out[ObservationFrame] + + def __init__( + self, + robot_id: str = "fakebot", + dof: int = 3, + step_hz: int = 100, + **kwargs: object, + ) -> None: + super().__init__(robot_id=robot_id, dof=dof, step_hz=step_hz, **kwargs) + self._robot_id = robot_id + self._owner = SimulatorOwnerThread(name="fake-runtime-owner") + self._state = self._owner.call( + lambda: FakeRuntimeState(robot_id=robot_id, dof=dof, step_hz=step_hz) + ) + self._runtime_stopped = False + + @rpc + def stop(self) -> None: + if self._runtime_stopped: + return + self._owner.stop() + self._runtime_stopped = True + super().stop() + + @rpc + def describe(self) -> RuntimeDescription: + """Return the fake runtime motor surface and stream metadata.""" + + return self._owner.call(lambda: module_runtime_description(self._state.describe())) + + @rpc + def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: + """Reset the fake benchmark episode synchronously.""" + + def _reset() -> EpisodeResetResponse: + response = self._state.reset(request) + self._publish_runtime_outputs(sequence=0, event="reset") + return response.model_copy( + update={ + "runtime_description": module_runtime_description(response.runtime_description), + "observations": [], + } + ) + + return self._owner.call(_reset) + + @rpc + def step(self, request: StepRequest) -> StepResponse: + """Advance the fake runtime by one synchronized benchmark tick.""" + + def _step() -> StepResponse: + if request.action.robot_id != self._robot_id: + raise ValueError( + f"unexpected robot_id {request.action.robot_id!r}; expected {self._robot_id!r}" + ) + if request.action.mode != CommandMode.POSITION: + raise ValueError(f"unsupported command mode {request.action.mode!s}") + response = self._state.step(request) + publish_output(self.motor_state, response.motor_state) + self._publish_runtime_outputs(sequence=response.motor_state.sequence, event="step") + return response.model_copy(update={"observations": []}) + + return self._owner.call(_step) + + @rpc + def score(self) -> ScoreOutput: + """Return the fake runtime score for the current episode.""" + + return self._owner.call(self._state.score) + + def _publish_runtime_outputs(self, sequence: int, event: str) -> None: + frame_id = "fake_camera" + pixel_value = np.uint8(sequence % 255) + image_data = np.full((2, 2, 3), pixel_value, dtype=np.uint8) + publish_output( + self.color_image, + Image(data=image_data, format=ImageFormat.RGB, frame_id=frame_id), + ) + publish_output( + self.camera_info, + CameraInfo.from_fov(fov_deg=60.0, width=2, height=2, frame_id=frame_id), + ) + publish_output( + self.runtime_event, + ObservationFrame( + stream="runtime_event", + kind=ObservationKind.TEXT, + inline_text=event, + metadata={"sequence": sequence}, + ), + ) diff --git a/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/server.py b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/server.py index 072d007044..54ca5f0a2c 100644 --- a/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/server.py +++ b/packages/dimos-fake-runtime-sidecar/src/dimos_fake_runtime_sidecar/server.py @@ -12,19 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""HTTP fake sidecar that speaks the DimOS runtime protocol.""" +"""Fake runtime state used by the DimOS Simulator Runtime Module.""" from __future__ import annotations -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import json import time from dimos_runtime_protocol.models import ( CommandMode, EpisodeResetRequest, EpisodeResetResponse, - HealthResponse, MotorDescription, MotorStateFrame, RobotMotorSurface, @@ -85,9 +82,13 @@ def step(self, request: StepRequest) -> StepResponse: previous = list(self.q) targets = request.action.q if request.action.names != self.names: - raise ValueError(f"motor names mismatch: expected {self.names}, got {request.action.names}") + raise ValueError( + f"motor names mismatch: expected {self.names}, got {request.action.names}" + ) if len(targets) != len(self.names): - raise ValueError(f"target length mismatch: expected {len(self.names)}, got {len(targets)}") + raise ValueError( + f"target length mismatch: expected {len(self.names)}, got {len(targets)}" + ) alpha = 0.35 self.q = [old + alpha * (target - old) for old, target in zip(self.q, targets, strict=True)] self.dq = [(new - old) * self.step_hz for old, new in zip(previous, self.q, strict=True)] @@ -121,84 +122,3 @@ def score(self) -> ScoreOutput: reason="fake runtime observed motor movement" if moved else "no movement observed", metrics={"sequence": self.sequence}, ) - - -class FakeRuntimeHandler(BaseHTTPRequestHandler): - """Request handler bound to one FakeRuntimeState instance.""" - - state: FakeRuntimeState - - def _write_json(self, status: int, payload: dict[str, object]) -> None: - data = json.dumps(payload).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def _read_json(self) -> dict[str, object]: - length = int(self.headers.get("Content-Length", "0")) - if length == 0: - return {} - data = self.rfile.read(length) - value = json.loads(data.decode("utf-8")) - if not isinstance(value, dict): - raise ValueError("expected JSON object") - return value - - def do_GET(self) -> None: # noqa: N802 - if self.path == "/health": - self._write_json(200, HealthResponse(ok=True, runtime_id="fake-runtime").model_dump()) - elif self.path == "/describe": - self._write_json(200, self.state.describe().model_dump()) - elif self.path == "/score": - self._write_json(200, self.state.score().model_dump()) - else: - self._write_json(404, {"error": f"unknown path {self.path}"}) - - def do_POST(self) -> None: # noqa: N802 - try: - body = self._read_json() - if self.path == "/reset": - request = EpisodeResetRequest.model_validate(body) - self._write_json(200, self.state.reset(request).model_dump()) - elif self.path == "/step": - request = StepRequest.model_validate(body) - self._write_json(200, self.state.step(request).model_dump()) - else: - self._write_json(404, {"error": f"unknown path {self.path}"}) - except Exception as exc: - self._write_json(400, {"error": str(exc)}) - - def log_message(self, format: str, *args: object) -> None: - return - - -def make_server(host: str, port: int, *, robot_id: str = "fakebot", dof: int = 3) -> ThreadingHTTPServer: - state = FakeRuntimeState(robot_id=robot_id, dof=dof) - - class BoundHandler(FakeRuntimeHandler): - pass - - BoundHandler.state = state - return ThreadingHTTPServer((host, port), BoundHandler) - - -def main() -> None: - import argparse - - parser = argparse.ArgumentParser(description="Run fake DimOS runtime sidecar") - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=8765) - parser.add_argument("--robot-id", default="fakebot") - parser.add_argument("--dof", type=int, default=3) - args = parser.parse_args() - server = make_server(args.host, args.port, robot_id=args.robot_id, dof=args.dof) - try: - server.serve_forever() - finally: - server.server_close() - - -if __name__ == "__main__": - main() diff --git a/packages/dimos-libero-pro-sidecar/pyproject.toml b/packages/dimos-libero-pro-sidecar/pyproject.toml index 1be8dc1b74..487237c9fb 100644 --- a/packages/dimos-libero-pro-sidecar/pyproject.toml +++ b/packages/dimos-libero-pro-sidecar/pyproject.toml @@ -6,17 +6,19 @@ build-backend = "hatchling.build" name = "dimos-libero-pro-sidecar" version = "0.1.0" description = "LIBERO-PRO runtime sidecar for DimOS benchmark plumbing" -requires-python = ">=3.10" +requires-python = ">=3.10,<3.13" dependencies = [ + "dimos", "dimos-runtime-protocol", + "libero", ] +[tool.uv.sources] +dimos = { path = "../..", editable = true } +dimos-runtime-protocol = { path = "../dimos-runtime-protocol", editable = true } + [project.optional-dependencies] assets = ["huggingface-hub"] -libero = ["libero"] - -[project.scripts] -dimos-libero-pro-sidecar = "dimos_libero_pro_sidecar.server:main" [tool.hatch.build.targets.wheel] packages = ["src/dimos_libero_pro_sidecar"] diff --git a/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/blueprint.py b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/blueprint.py new file mode 100644 index 0000000000..2400a2f5ba --- /dev/null +++ b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/blueprint.py @@ -0,0 +1,74 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Blueprint helpers for the LIBERO-PRO simulator runtime module.""" + +from __future__ import annotations + +from pathlib import Path + +from dimos.core.coordination.blueprints import Blueprint, autoconnect +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment +from dimos_libero_pro_sidecar.module import LiberoProRuntimeModule + +LIBERO_PRO_RUNTIME_ENV_NAME = "dimos-libero-pro-runtime" +LIBERO_PRO_RUNTIME_PROJECT = Path(__file__).resolve().parents[2] + + +def libero_pro_runtime_blueprint( + *, + bddl_root: str | Path, + init_states_root: str | Path, + runtime: PythonProjectRuntimeEnvironment | None = None, + benchmark_name: str = "libero_90", + robot_id: str = "panda", + task_order_index: int = 0, + task_index: int = 0, + init_state_index: int = 0, + controller: str = "JOINT_POSITION", + camera_names: tuple[str, ...] = ("agentview",), + control_freq: int = 20, + horizon: int = 1000, + seed: int | None = None, + allow_asset_bootstrap: bool = False, + visualize: bool = False, +) -> Blueprint: + """Create a placed LIBERO-PRO simulator runtime module blueprint.""" + + environment = runtime or PythonProjectRuntimeEnvironment( + name=LIBERO_PRO_RUNTIME_ENV_NAME, + project=LIBERO_PRO_RUNTIME_PROJECT, + ) + return ( + autoconnect( + LiberoProRuntimeModule.blueprint( + bddl_root=bddl_root, + init_states_root=init_states_root, + benchmark_name=benchmark_name, + robot_id=robot_id, + task_order_index=task_order_index, + task_index=task_index, + init_state_index=init_state_index, + controller=controller, + camera_names=camera_names, + control_freq=control_freq, + horizon=horizon, + seed=seed, + allow_asset_bootstrap=allow_asset_bootstrap, + visualize=visualize, + ) + ) + .runtime_environments(environment) + .runtime_placements({LiberoProRuntimeModule: environment.name}) + ) diff --git a/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/module.py b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/module.py new file mode 100644 index 0000000000..804e263695 --- /dev/null +++ b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/module.py @@ -0,0 +1,232 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""DimOS module wrapper for LIBERO-PRO benchmark runtimes.""" + +from __future__ import annotations + +from pathlib import Path + +from dimos_runtime_protocol.models import ( + EpisodeResetRequest, + EpisodeResetResponse, + MotorStateFrame, + ObservationFrame, + ObservationKind, + RuntimeDescription, + ScoreOutput, + StepRequest, + StepResponse, +) +import numpy as np + +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import Out +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.simulation.runtime_module import ( + SimulatorOwnerThread, + module_runtime_description, + publish_output, +) +from dimos_libero_pro_sidecar.server import LiberoProRuntimeConfig, LiberoProRuntimeState + + +class LiberoProRuntimeModuleConfig(ModuleConfig): + bddl_root: str | Path + init_states_root: str | Path + benchmark_name: str = "libero_90" + robot_id: str = "panda" + task_order_index: int = 0 + task_index: int = 0 + init_state_index: int = 0 + controller: str = "JOINT_POSITION" + camera_names: tuple[str, ...] = ("agentview",) + control_freq: int = 20 + horizon: int = 1000 + seed: int | None = None + allow_asset_bootstrap: bool = False + visualize: bool = False + + +class LiberoProRuntimeModule(Module): + """First-class DimOS module for selected LIBERO-PRO benchmark tasks.""" + + config: LiberoProRuntimeModuleConfig + + motor_state: Out[MotorStateFrame] + color_image: Out[Image] + camera_info: Out[CameraInfo] + runtime_event: Out[ObservationFrame] + + def __init__( + self, + bddl_root: str | Path, + init_states_root: str | Path, + benchmark_name: str = "libero_90", + robot_id: str = "panda", + task_order_index: int = 0, + task_index: int = 0, + init_state_index: int = 0, + controller: str = "JOINT_POSITION", + camera_names: tuple[str, ...] = ("agentview",), + control_freq: int = 20, + horizon: int = 1000, + seed: int | None = None, + allow_asset_bootstrap: bool = False, + visualize: bool = False, + **kwargs: object, + ) -> None: + super().__init__( + bddl_root=bddl_root, + init_states_root=init_states_root, + benchmark_name=benchmark_name, + robot_id=robot_id, + task_order_index=task_order_index, + task_index=task_index, + init_state_index=init_state_index, + controller=controller, + camera_names=camera_names, + control_freq=control_freq, + horizon=horizon, + seed=seed, + allow_asset_bootstrap=allow_asset_bootstrap, + visualize=visualize, + **kwargs, + ) + self._owner = SimulatorOwnerThread(name="libero-pro-runtime-owner") + if visualize: + self._owner.stop() + raise ValueError( + "visualize=True is not supported by LiberoProRuntimeModule until " + "main-thread simulator workers are available" + ) + self._runtime_config = LiberoProRuntimeConfig( + host="127.0.0.1", + port=0, + benchmark_name=benchmark_name, + bddl_root=Path(bddl_root), + init_states_root=Path(init_states_root), + robot_id=robot_id, + task_order_index=task_order_index, + task_index=task_index, + init_state_index=init_state_index, + controller=controller, + camera_names=tuple(camera_names), + control_freq=control_freq, + horizon=horizon, + seed=seed, + allow_asset_bootstrap=allow_asset_bootstrap, + visualize=visualize, + ) + self._state: LiberoProRuntimeState | None = None + self._runtime_stopped = False + + @rpc + def stop(self) -> None: + if self._runtime_stopped: + return + self._owner.stop() + self._runtime_stopped = True + super().stop() + + @rpc + def describe(self) -> RuntimeDescription: + """Return LIBERO-PRO runtime metadata without HTTP capability markers.""" + + def _describe() -> RuntimeDescription: + state = self._state_or_create() + return module_runtime_description( + state.describe(), camera_streams=list(state.config.camera_names) + ) + + return self._owner.call(_describe) + + @rpc + def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: + """Reset the selected LIBERO-PRO task on the simulator owner thread.""" + + def _reset() -> EpisodeResetResponse: + state = self._state_or_create() + response = state.reset(request) + self._publish_observation_outputs(response.observations, event="reset") + runtime_description = module_runtime_description( + response.runtime_description, camera_streams=list(state.config.camera_names) + ) + return response.model_copy( + update={"runtime_description": runtime_description, "observations": []} + ) + + return self._owner.call(_reset) + + @rpc + def step(self, request: StepRequest) -> StepResponse: + """Advance LIBERO-PRO by one synchronized benchmark tick.""" + + def _step() -> StepResponse: + state = self._state_or_create() + response = state.step(request) + publish_output(self.motor_state, response.motor_state) + self._publish_observation_outputs(response.observations, event="step") + return response.model_copy(update={"observations": []}) + + return self._owner.call(_step) + + @rpc + def score(self) -> ScoreOutput: + """Return the current LIBERO-PRO benchmark score.""" + + return self._owner.call(lambda: self._state_or_create().score()) + + def _state_or_create(self) -> LiberoProRuntimeState: + if self._state is None: + self._state = LiberoProRuntimeState(self._runtime_config) + return self._state + + def _publish_observation_outputs( + self, observations: list[ObservationFrame], event: str + ) -> None: + for observation in observations: + if observation.kind == ObservationKind.STATE: + publish_output(self.runtime_event, observation) + state = self._state_or_create() + for camera_name in state.config.camera_names: + image = state._last_obs.get(f"{camera_name}_image") + if image is None: + continue + array = np.asarray(image) + publish_output( + self.color_image, + Image(data=array, format=ImageFormat.RGB, frame_id=camera_name), + ) + height, width = array.shape[:2] + publish_output( + self.camera_info, + CameraInfo.from_fov( + fov_deg=45.0, + width=width, + height=height, + frame_id=camera_name, + ), + ) + publish_output( + self.runtime_event, + ObservationFrame( + stream="runtime_event", + kind=ObservationKind.TEXT, + inline_text=event, + metadata={"sequence": state._sequence}, + ), + ) diff --git a/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/server.py b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/server.py index 55527cb931..5e66208d00 100644 --- a/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/server.py +++ b/packages/dimos-libero-pro-sidecar/src/dimos_libero_pro_sidecar/server.py @@ -12,32 +12,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Single-threaded HTTP sidecar for registered LIBERO-PRO tasks.""" +"""LIBERO-PRO runtime state for Simulator Runtime Modules.""" from __future__ import annotations -import argparse from collections.abc import Sequence from dataclasses import dataclass -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, HTTPServer from io import BytesIO -import json from pathlib import Path import time -from typing import ClassVar, Protocol, cast -from urllib.parse import unquote, urlparse +from typing import Protocol, cast from dimos_runtime_protocol import ( CommandMode, EpisodeResetRequest, EpisodeResetResponse, - HealthResponse, MotorDescription, MotorStateFrame, ObservationFrame, ObservationKind, - ProtocolVersion, RobotMotorSurface, RuntimeDescription, ScoreOutput, @@ -413,79 +406,6 @@ def _store_payload(self, stream: str, tick_id: int, value: object) -> tuple[str, return payload_id, payload -class LiberoProRuntimeHandler(BaseHTTPRequestHandler): - state: ClassVar[LiberoProRuntimeState] - - def do_GET(self) -> None: - if self.path == "/health": - try: - validate_assets(self.state.config) - self._write_model( - HealthResponse(ok=True, runtime_id="libero-pro", protocol=ProtocolVersion()) - ) - except Exception as exc: - self._write_model( - HealthResponse(ok=False, runtime_id="libero-pro", detail=str(exc)), - status=HTTPStatus.SERVICE_UNAVAILABLE, - ) - elif self.path == "/describe": - self._write_model(self.state.describe()) - elif self.path == "/score": - self._write_model(self.state.score()) - elif self.path.startswith("/payloads/"): - payload_id = unquote(urlparse(self.path).path.removeprefix("/payloads/")) - try: - self._write_bytes( - self.state.payload_bytes(payload_id), content_type="application/x-npy" - ) - except FileNotFoundError: - self.send_error(HTTPStatus.NOT_FOUND) - else: - self.send_error(HTTPStatus.NOT_FOUND) - - def do_POST(self) -> None: - try: - body = self.rfile.read(int(self.headers.get("content-length", "0"))).decode("utf-8") - payload = json.loads(body) if body else {} - if self.path == "/reset": - self._write_model(self.state.reset(EpisodeResetRequest.model_validate(payload))) - elif self.path == "/step": - self._write_model(self.state.step(StepRequest.model_validate(payload))) - else: - self.send_error(HTTPStatus.NOT_FOUND) - except Exception as exc: - self._write_json(HTTPStatus.BAD_REQUEST, {"code": "bad_request", "message": str(exc)}) - - def log_message(self, format: str, *args: object) -> None: - return - - def _write_model(self, model: object, *, status: HTTPStatus = HTTPStatus.OK) -> None: - dump = getattr(model, "model_dump", None) - self._write_json(status, dump(mode="json") if callable(dump) else model) - - def _write_json(self, status: HTTPStatus, payload: object) -> None: - data = json.dumps(payload).encode("utf-8") - self.send_response(status) - self.send_header("content-type", "application/json") - self.send_header("content-length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def _write_bytes(self, payload: bytes, *, content_type: str) -> None: - self.send_response(HTTPStatus.OK) - self.send_header("content-type", content_type) - self.send_header("content-length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - -def make_server( - config: LiberoProRuntimeConfig, *, state: LiberoProRuntimeState | None = None -) -> HTTPServer: - LiberoProRuntimeHandler.state = state or LiberoProRuntimeState(config) - return HTTPServer((config.host, config.port), LiberoProRuntimeHandler) - - def validate_assets(config: LiberoProRuntimeConfig) -> None: if config.allow_asset_bootstrap: bootstrap_assets(config) @@ -527,50 +447,6 @@ def bootstrap_assets(config: LiberoProRuntimeConfig) -> None: ) -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=8767) - parser.add_argument("--benchmark-name", required=True) - parser.add_argument("--bddl-root", type=Path, required=True) - parser.add_argument("--init-states-root", type=Path, required=True) - parser.add_argument("--robot-id", default="panda") - parser.add_argument("--task-order-index", type=int, default=0) - parser.add_argument("--task-index", type=int, default=0) - parser.add_argument("--init-state-index", type=int, default=0) - parser.add_argument("--controller", default="JOINT_POSITION") - parser.add_argument("--control-freq", type=int, default=20) - parser.add_argument("--horizon", type=int, default=1000) - parser.add_argument("--seed", type=int, default=None) - parser.add_argument("--camera-name", action="append", dest="camera_names") - parser.add_argument("--allow-asset-bootstrap", action="store_true") - parser.add_argument("--visualize", action="store_true") - args = parser.parse_args() - config = LiberoProRuntimeConfig( - host=args.host, - port=args.port, - benchmark_name=args.benchmark_name, - bddl_root=args.bddl_root, - init_states_root=args.init_states_root, - robot_id=args.robot_id, - task_order_index=args.task_order_index, - task_index=args.task_index, - init_state_index=args.init_state_index, - controller=args.controller, - camera_names=tuple(args.camera_names or ["agentview"]), - control_freq=args.control_freq, - horizon=args.horizon, - seed=args.seed, - allow_asset_bootstrap=args.allow_asset_bootstrap, - visualize=args.visualize, - ) - server = make_server(config) - try: - server.serve_forever() - finally: - server.server_close() - - def _task_bddl_file(config: LiberoProRuntimeConfig, benchmark: object, task: object) -> Path: get_path = getattr(benchmark, "get_task_bddl_file_path", None) if callable(get_path): @@ -674,7 +550,3 @@ def _success_from_info(info: dict[str, object]) -> bool | None: if isinstance(value, int | float): return bool(value) return None - - -if __name__ == "__main__": - main() diff --git a/packages/dimos-robosuite-sidecar/pyproject.toml b/packages/dimos-robosuite-sidecar/pyproject.toml index bb0abe0f83..1aaa3c54c6 100644 --- a/packages/dimos-robosuite-sidecar/pyproject.toml +++ b/packages/dimos-robosuite-sidecar/pyproject.toml @@ -6,16 +6,17 @@ build-backend = "hatchling.build" name = "dimos-robosuite-sidecar" version = "0.1.0" description = "Robosuite runtime sidecar for DimOS benchmark plumbing demos" -requires-python = ">=3.10" +requires-python = ">=3.10,<3.13" dependencies = [ + "dimos", "dimos-runtime-protocol", + "mujoco<3.10", + "robosuite>=1.5", ] -[project.optional-dependencies] -robosuite = ["robosuite>=1.5"] - -[project.scripts] -dimos-robosuite-sidecar = "dimos_robosuite_sidecar.server:main" +[tool.uv.sources] +dimos = { path = "../..", editable = true } +dimos-runtime-protocol = { path = "../dimos-runtime-protocol", editable = true } [tool.hatch.build.targets.wheel] packages = ["src/dimos_robosuite_sidecar"] diff --git a/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/blueprint.py b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/blueprint.py new file mode 100644 index 0000000000..562aa836d9 --- /dev/null +++ b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/blueprint.py @@ -0,0 +1,68 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Blueprint helpers for the Robosuite simulator runtime module.""" + +from __future__ import annotations + +from pathlib import Path + +from dimos.core.coordination.blueprints import Blueprint, autoconnect +from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment +from dimos_robosuite_sidecar.module import RobosuiteRuntimeModule + +ROBOSUITE_RUNTIME_ENV_NAME = "dimos-robosuite-runtime" +ROBOSUITE_RUNTIME_PROJECT = Path(__file__).resolve().parents[2] + + +def robosuite_runtime_blueprint( + runtime: PythonProjectRuntimeEnvironment | None = None, + *, + env_name: str = "Lift", + robot_id: str = "panda", + robot_model: str = "Panda", + controller: str = "JOINT_POSITION", + control_freq: int = 20, + horizon: int = 200, + camera_name: str = "agentview", + seed: int | None = None, + visualize: bool = False, + image_dump_dir: str | Path | None = None, + image_dump_every: int = 0, +) -> Blueprint: + """Create a placed Robosuite simulator runtime module blueprint.""" + + environment = runtime or PythonProjectRuntimeEnvironment( + name=ROBOSUITE_RUNTIME_ENV_NAME, + project=ROBOSUITE_RUNTIME_PROJECT, + ) + return ( + autoconnect( + RobosuiteRuntimeModule.blueprint( + env_name=env_name, + robot_id=robot_id, + robot_model=robot_model, + controller=controller, + control_freq=control_freq, + horizon=horizon, + camera_name=camera_name, + seed=seed, + visualize=visualize, + image_dump_dir=image_dump_dir, + image_dump_every=image_dump_every, + ) + ) + .runtime_environments(environment) + .runtime_placements({RobosuiteRuntimeModule: environment.name}) + ) diff --git a/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/module.py b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/module.py new file mode 100644 index 0000000000..58f7493557 --- /dev/null +++ b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/module.py @@ -0,0 +1,251 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""DimOS module wrapper for the Robosuite benchmark runtime.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +from dimos_runtime_protocol.models import ( + EpisodeResetRequest, + EpisodeResetResponse, + MotorStateFrame, + ObservationFrame, + ObservationKind, + RuntimeDescription, + ScoreOutput, + StepRequest, + StepResponse, +) +import numpy as np + +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import Out +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.simulation.runtime_module import ( + InlineSimulatorExecutor, + SimulatorExecutor, + SimulatorOwnerThread, + module_runtime_description, + publish_output, +) +from dimos_robosuite_sidecar.server import RobosuiteRuntimeConfig, RobosuiteRuntimeState + + +class RobosuiteRuntimeModuleConfig(ModuleConfig): + env_name: str = "Lift" + robot_id: str = "panda" + robot_model: str = "Panda" + controller: str = "JOINT_POSITION" + control_freq: int = 20 + horizon: int = 200 + camera_name: str = "agentview" + seed: int | None = None + visualize: bool = False + image_dump_dir: str | Path | None = None + image_dump_every: int = 0 + + +class RobosuiteRuntimeModule(Module): + """First-class DimOS module for Robosuite benchmark runtimes.""" + + config: RobosuiteRuntimeModuleConfig + + motor_state: Out[MotorStateFrame] + color_image: Out[Image] + camera_info: Out[CameraInfo] + runtime_event: Out[ObservationFrame] + + def __init__( + self, + env_name: str = "Lift", + robot_id: str = "panda", + robot_model: str = "Panda", + controller: str = "JOINT_POSITION", + control_freq: int = 20, + horizon: int = 200, + camera_name: str = "agentview", + seed: int | None = None, + visualize: bool = False, + image_dump_dir: str | Path | None = None, + image_dump_every: int = 0, + **kwargs: object, + ) -> None: + super().__init__( + env_name=env_name, + robot_id=robot_id, + robot_model=robot_model, + controller=controller, + control_freq=control_freq, + horizon=horizon, + camera_name=camera_name, + seed=seed, + visualize=visualize, + image_dump_dir=image_dump_dir, + image_dump_every=image_dump_every, + **kwargs, + ) + self._owner: SimulatorExecutor = ( + InlineSimulatorExecutor() + if visualize + else SimulatorOwnerThread(name="robosuite-runtime-owner") + ) + self._runtime_config = RobosuiteRuntimeConfig( + host="127.0.0.1", + port=0, + env_name=env_name, + robot_id=robot_id, + robot_model=robot_model, + controller=controller, + control_freq=control_freq, + horizon=horizon, + camera_name=camera_name, + seed=seed, + visualize=visualize, + image_dump_dir=str(image_dump_dir) if image_dump_dir is not None else None, + image_dump_every=image_dump_every, + ) + self._state: RobosuiteRuntimeState | None = None + self._runtime_stopped = False + + @rpc + def stop(self) -> None: + if self._runtime_stopped: + return + self._owner.call(self._close_state) + self._owner.stop() + self._runtime_stopped = True + super().stop() + + @rpc + def describe(self) -> RuntimeDescription: + """Return Robosuite runtime metadata without HTTP capability markers.""" + + def _describe() -> RuntimeDescription: + state = self._state_or_create() + return module_runtime_description( + state.describe(), camera_streams=[state.config.camera_name] + ) + + return self._owner.call(_describe) + + @rpc + def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: + """Reset the Robosuite episode on the simulator owner thread.""" + + def _reset() -> EpisodeResetResponse: + state = self._state_or_create() + response = state.reset(request) + self._publish_observation_outputs(response.observations, event="reset") + runtime_description = module_runtime_description( + response.runtime_description, camera_streams=[state.config.camera_name] + ) + return response.model_copy( + update={"runtime_description": runtime_description, "observations": []} + ) + + return self._owner.call(_reset) + + @rpc + def step(self, request: StepRequest) -> StepResponse: + """Advance Robosuite by one synchronized benchmark tick.""" + + def _step() -> StepResponse: + state = self._state_or_create() + response = state.step(request) + publish_output(self.motor_state, response.motor_state) + self._publish_observation_outputs(response.observations, event="step") + return response.model_copy(update={"observations": []}) + + return self._owner.call(_step) + + @rpc + def score(self) -> ScoreOutput: + """Return the current Robosuite benchmark score.""" + + return self._owner.call(lambda: self._state_or_create().score()) + + def _state_or_create(self) -> RobosuiteRuntimeState: + if self._state is None: + self._state = RobosuiteRuntimeState(self._runtime_config) + return self._state + + def _close_state(self) -> None: + if self._state is None: + return + env = getattr(self._state, "_env", None) + close = getattr(env, "close", None) + if callable(close): + close() + self._state = None + + def _publish_observation_outputs( + self, observations: list[ObservationFrame], event: str + ) -> None: + for observation in observations: + if observation.kind == ObservationKind.STATE: + publish_output(self.runtime_event, observation) + state = self._state_or_create() + image_frame = next( + ( + observation + for observation in observations + if observation.kind == ObservationKind.IMAGE and observation.data_ref is not None + ), + None, + ) + if image_frame is not None: + payload_id = image_frame.data_ref.removeprefix("/payloads/") + array = np.load(BytesIO(state.payload_bytes(payload_id)), allow_pickle=False) + metadata = image_frame.metadata or {} + frame_id = str(metadata.get("camera_name", state.config.camera_name)) + fov_y_deg = float(metadata.get("fov_y_deg", state._camera_fov_deg())) + if metadata.get("image_convention") == "opengl": + array = np.ascontiguousarray(np.flipud(array)) + else: + image = state._camera_image() + if image is None: + return + array = np.asarray(image) + frame_id = state.config.camera_name + fov_y_deg = state._camera_fov_deg() + if array.size == 0: + return + publish_output( + self.color_image, + Image(data=array, format=ImageFormat.RGB, frame_id=frame_id), + ) + height, width = array.shape[:2] + publish_output( + self.camera_info, + CameraInfo.from_fov( + fov_deg=fov_y_deg, + width=width, + height=height, + frame_id=frame_id, + ), + ) + publish_output( + self.runtime_event, + ObservationFrame( + stream="runtime_event", + kind=ObservationKind.TEXT, + inline_text=event, + metadata={"sequence": state._sequence, "camera": frame_id}, + ), + ) diff --git a/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/server.py b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/server.py index 9cc23c77bf..d72c652605 100644 --- a/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/server.py +++ b/packages/dimos-robosuite-sidecar/src/dimos_robosuite_sidecar/server.py @@ -12,28 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""HTTP sidecar for a narrow Robosuite Panda Lift runtime profile.""" +"""Robosuite Panda Lift runtime state for Simulator Runtime Modules.""" from __future__ import annotations -import argparse +from collections.abc import Mapping, Sequence from dataclasses import dataclass import hashlib -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, HTTPServer from io import BytesIO -import json from pathlib import Path import time from types import ModuleType -from typing import ClassVar, Mapping, Sequence, cast -from urllib.parse import unquote, urlparse +from typing import Any, cast from dimos_runtime_protocol import ( CommandMode, EpisodeResetRequest, EpisodeResetResponse, - HealthResponse, MotorDescription, MotorStateFrame, ObservationFrame, @@ -82,8 +77,8 @@ class RobosuiteRuntimeState: def __init__(self, config: RobosuiteRuntimeConfig) -> None: self.config = config - self._robosuite = require_robosuite() - self._env = self._make_env() + self._robosuite: Any = require_robosuite() + self._env: Any = self._make_env() self._episode_id = "" self._sequence = 0 self._last_obs: Mapping[str, object] = {} @@ -99,11 +94,11 @@ def __init__(self, config: RobosuiteRuntimeConfig) -> None: f"action_dim={len(self._action_low)} motors={len(self.motor_names)}" ) - def _make_env(self) -> object: + def _make_env(self) -> Any: robosuite = self._robosuite - controllers = getattr(robosuite, "controllers") + controllers = robosuite.controllers controller_config = self._controller_config(controllers) - make_env = getattr(robosuite, "make") + make_env = robosuite.make return make_env( env_name=self.config.env_name, robots=self.config.robot_model, @@ -119,14 +114,13 @@ def _make_env(self) -> object: camera_depths=False, control_freq=self.config.control_freq, horizon=self.config.horizon, - seed=self.config.seed, ) - def _controller_config(self, controllers: object) -> object: - load_composite = getattr(controllers, "load_composite_controller_config") + def _controller_config(self, controllers: Any) -> object: + load_composite = controllers.load_composite_controller_config controller_config = load_composite(controller="BASIC") if self.config.controller in {"JOINT_POSITION", "PANDA_JOINT_POSITION"}: - load_part = getattr(controllers, "load_part_controller_config") + load_part = controllers.load_part_controller_config right_config = load_part(default_controller="JOINT_POSITION") right_config["gripper"] = {"type": "GRIP"} controller_config["body_parts"]["right"] = right_config @@ -142,7 +136,9 @@ def _action_bounds(self) -> tuple[list[float], list[float]]: def _motor_names(self) -> list[str]: # Narrow v1 profile: Panda arm joints plus scalar gripper command. if self.config.robot_model != "Panda": - raise RuntimeError(f"unsupported robot model for v1 Robosuite sidecar: {self.config.robot_model}") + raise RuntimeError( + f"unsupported robot model for v1 Robosuite sidecar: {self.config.robot_model}" + ) return [f"{self.config.robot_id}/joint{i + 1}" for i in range(7)] + [ f"{self.config.robot_id}/gripper" ] @@ -152,7 +148,7 @@ def describe(self) -> RuntimeDescription: runtime_id="robosuite-panda-lift", backend="robosuite", protocol=ProtocolVersion(), - capabilities=["sync-http", "whole-body-motor-position", "robosuite-panda-lift"], + capabilities=["whole-body-motor-position", "robosuite-panda-lift"], robot_surfaces=[ RobotMotorSurface( robot_id=self.config.robot_id, @@ -179,10 +175,11 @@ def describe(self) -> RuntimeDescription: def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: self._episode_id = request.episode_id self._sequence = 0 - self._last_obs = cast(Mapping[str, object], self._env.reset()) # type: ignore[attr-defined] + self._last_obs = cast("Mapping[str, object]", self._env.reset()) # type: ignore[attr-defined] self._last_reward = 0.0 self._last_done = False self._last_success = None + self._force_refresh_camera_observation_if_visual() # Store protocol payloads before updating the interactive viewer. In # visual mode Robosuite/MuJoCo viewer updates can touch render buffers # that observation arrays may reference, causing alternating/stale @@ -205,10 +202,11 @@ def step(self, request: StepRequest) -> StepResponse: action = self._action_from_request(request) obs, reward, done, info = self._env.step(action) # type: ignore[attr-defined] self._sequence += 1 - self._last_obs = cast(Mapping[str, object], obs) + self._last_obs = cast("Mapping[str, object]", obs) self._last_reward = float(reward) self._last_done = bool(done) self._last_success = _success_from_info(info) + self._force_refresh_camera_observation_if_visual() motor_state = self._motor_state(request.tick_id) # Store protocol payloads before updating the interactive viewer; see # reset() for the render-buffer ordering rationale. @@ -228,7 +226,7 @@ def step(self, request: StepRequest) -> StepResponse: def _render_if_enabled(self) -> None: if not self.config.visualize: return - render = getattr(self._env, "render") + render = self._env.render render() def score(self) -> ScoreOutput: @@ -238,14 +236,20 @@ def score(self) -> ScoreOutput: success=success, score=1.0 if success else 0.0, reason="robosuite success flag" if success else "success not observed", - metrics={"reward": self._last_reward, "done": self._last_done, "sequence": self._sequence}, + metrics={ + "reward": self._last_reward, + "done": self._last_done, + "sequence": self._sequence, + }, ) def _action_from_request(self, request: StepRequest) -> object: import numpy as np if len(request.action.q) != len(self.motor_names): - raise ValueError(f"expected {len(self.motor_names)} q targets, got {len(request.action.q)}") + raise ValueError( + f"expected {len(self.motor_names)} q targets, got {len(request.action.q)}" + ) values = np.array(request.action.q, dtype=np.float64) low = np.array(self._action_low, dtype=np.float64) high = np.array(self._action_high, dtype=np.float64) @@ -256,8 +260,8 @@ def _motor_state(self, tick_id: int) -> MotorStateFrame: joint_dq = _float_list(self._last_obs.get("robot0_joint_vel", [])) gripper_q = _float_list(self._last_obs.get("robot0_gripper_qpos", [])) gripper_dq = _float_list(self._last_obs.get("robot0_gripper_qvel", [])) - q = (joint_q[:7] + [_mean_or_zero(gripper_q)])[: len(self.motor_names)] - dq = (joint_dq[:7] + [_mean_or_zero(gripper_dq)])[: len(self.motor_names)] + q = [*joint_q[:7], _mean_or_zero(gripper_q)][: len(self.motor_names)] + dq = [*joint_dq[:7], _mean_or_zero(gripper_dq)][: len(self.motor_names)] if len(q) != len(self.motor_names): q = q + [0.0] * (len(self.motor_names) - len(q)) if len(dq) != len(self.motor_names): @@ -319,6 +323,22 @@ def _camera_image(self) -> object | None: return None return _array_copy(cached_image) + def _force_refresh_camera_observation_if_visual(self) -> None: + """Refresh offscreen camera observations before viewer rendering. + + In visual mode, Robosuite's `env.step()` observation can lag the + interactive viewer. Force-refreshing before `env.render()` keeps the + streamed camera moving while preserving the old render-buffer ordering: + copy/store camera payloads first, then let the viewer touch MuJoCo render + buffers. + """ + + if not self.config.visualize: + return + get_observations = getattr(self._env, "_get_observations", None) + if callable(get_observations): + self._last_obs = cast("Mapping[str, object]", get_observations(force_update=True)) + def _store_payload(self, stream: str, tick_id: int, value: object) -> tuple[str, bytes]: payload_id = f"{stream}-{tick_id:06d}-{self._sequence:06d}.npy" payload_bytes = _npy_bytes(value) @@ -334,14 +354,15 @@ def _dump_source_image_if_enabled(self, stem: str, value: object) -> None: return if self._sequence % self.config.image_dump_every != 0: return + import numpy as np + output_dir = Path(self.config.image_dump_dir) - _write_rgb_jpeg(output_dir / f"{stem}_server_raw.jpg", value) + rgb = np.asarray(value) + _write_rgb_jpeg(output_dir / f"{stem}_server_raw.jpg", rgb) if self._image_convention() == "opengl": - import numpy as np - - _write_rgb_jpeg(output_dir / f"{stem}_server_display.jpg", np.flipud(np.asarray(value))) + _write_rgb_jpeg(output_dir / f"{stem}_server_display.jpg", np.flipud(rgb)) else: - _write_rgb_jpeg(output_dir / f"{stem}_server_display.jpg", value) + _write_rgb_jpeg(output_dir / f"{stem}_server_display.jpg", rgb) def payload_bytes(self, payload_id: str) -> bytes: try: @@ -351,8 +372,8 @@ def payload_bytes(self, payload_id: str) -> bytes: def _camera_fov_deg(self) -> float: try: - sim = getattr(self._env, "sim") - model = getattr(sim, "model") + sim = self._env.sim + model = sim.model camera_id = model.camera_name2id(self.config.camera_name) return float(model.cam_fovy[camera_id]) except Exception: @@ -363,108 +384,6 @@ def _image_convention(self) -> str: return str(getattr(macros, "IMAGE_CONVENTION", "opengl")) -class RobosuiteRuntimeHandler(BaseHTTPRequestHandler): - state: ClassVar[RobosuiteRuntimeState] - - def do_GET(self) -> None: - if self.path == "/health": - self._write_model( - HealthResponse(ok=True, runtime_id="robosuite-panda-lift", protocol=ProtocolVersion()) - ) - elif self.path == "/describe": - self._write_model(self.state.describe()) - elif self.path == "/score": - self._write_model(self.state.score()) - elif self.path.startswith("/payloads/"): - payload_id = unquote(urlparse(self.path).path.removeprefix("/payloads/")) - try: - self._write_bytes(self.state.payload_bytes(payload_id), content_type="application/x-npy") - except FileNotFoundError: - self.send_error(HTTPStatus.NOT_FOUND) - else: - self.send_error(HTTPStatus.NOT_FOUND) - - def do_POST(self) -> None: - try: - body = self.rfile.read(int(self.headers.get("content-length", "0"))).decode("utf-8") - payload = json.loads(body) if body else {} - if self.path == "/reset": - self._write_model(self.state.reset(EpisodeResetRequest.model_validate(payload))) - elif self.path == "/step": - self._write_model(self.state.step(StepRequest.model_validate(payload))) - else: - self.send_error(HTTPStatus.NOT_FOUND) - except Exception as exc: - self.send_response(HTTPStatus.BAD_REQUEST) - self.send_header("content-type", "application/json") - self.end_headers() - self.wfile.write(json.dumps({"code": "bad_request", "message": str(exc)}).encode()) - - def log_message(self, format: str, *args: object) -> None: - return - - def _write_model(self, model: object) -> None: - model_dump = getattr(model, "model_dump", None) - payload = model_dump(mode="json") if callable(model_dump) else model - self.send_response(HTTPStatus.OK) - self.send_header("content-type", "application/json") - self.end_headers() - self.wfile.write(json.dumps(payload).encode("utf-8")) - - def _write_bytes(self, payload: bytes, *, content_type: str) -> None: - self.send_response(HTTPStatus.OK) - self.send_header("content-type", content_type) - self.send_header("content-length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - -def make_server(config: RobosuiteRuntimeConfig) -> HTTPServer: - RobosuiteRuntimeHandler.state = RobosuiteRuntimeState(config) - # Robosuite/MuJoCo render contexts are thread-sensitive. Keep all env reset, - # step, offscreen camera, and interactive viewer calls on the server thread - # instead of using ThreadingHTTPServer worker threads. - return HTTPServer((config.host, config.port), RobosuiteRuntimeHandler) - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=8766) - parser.add_argument("--env-name", default="Lift") - parser.add_argument("--robot-id", default="panda") - parser.add_argument("--robot-model", default="Panda") - parser.add_argument("--controller", default="JOINT_POSITION") - parser.add_argument("--control-freq", type=int, default=100) - parser.add_argument("--horizon", type=int, default=200) - parser.add_argument("--camera-name", default="agentview") - parser.add_argument("--seed", type=int, default=None) - parser.add_argument("--visualize", action="store_true") - parser.add_argument("--image-dump-dir", default=None) - parser.add_argument("--image-dump-every", type=int, default=0) - args = parser.parse_args() - config = RobosuiteRuntimeConfig( - host=args.host, - port=args.port, - env_name=args.env_name, - robot_id=args.robot_id, - robot_model=args.robot_model, - controller=args.controller, - control_freq=args.control_freq, - horizon=args.horizon, - camera_name=args.camera_name, - seed=args.seed, - visualize=args.visualize, - image_dump_dir=args.image_dump_dir, - image_dump_every=args.image_dump_every, - ) - server = make_server(config) - try: - server.serve_forever() - finally: - server.server_close() - - def _float_list(value: object) -> list[float]: tolist = getattr(value, "tolist", None) if callable(tolist): @@ -551,7 +470,3 @@ def _camera_mount(camera_name: str) -> str: if "robotview" in camera_name: return "robot_external" return "scene" - - -if __name__ == "__main__": - main() diff --git a/packages/dimos-robosuite-sidecar/uv.lock b/packages/dimos-robosuite-sidecar/uv.lock new file mode 100644 index 0000000000..30cc528e0b --- /dev/null +++ b/packages/dimos-robosuite-sidecar/uv.lock @@ -0,0 +1,4120 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.13" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + +[[package]] +name = "addict" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ef/fd7649da8af11d93979831e8f1f8097e85e82d5bfeabc8c68b39175d8e75/addict-2.4.0.tar.gz", hash = "sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494", size = 9186, upload-time = "2020-11-21T16:21:31.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, + { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, + { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, + { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, + { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, + { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, +] + +[[package]] +name = "aiohttp-jinja2" +version = "1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/39/da5a94dd89b1af7241fb7fc99ae4e73505b5f898b540b6aba6dc7afe600e/aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2", size = 53057, upload-time = "2023-11-18T15:30:52.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/90/65238d4246307195411b87a07d03539049819b022c01bcc773826f600138/aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7", size = 11736, upload-time = "2023-11-18T15:30:50.743Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "bleak" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-radios", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/df/05a3f80ca8e3f7f5b0dba68a9e618147c909ccdba1468f07487dc8d72a9d/bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f", size = 125293, upload-time = "2026-05-02T23:01:04.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "cmeel" +version = "0.60.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/46/ddc7df697e49cae32b1a97b3e1a3b47b815238f9059312f987bc62a2e756/cmeel-0.60.1.tar.gz", hash = "sha256:3e0b92eb933a3693ad3f1da8aae31defcbee5f25969daaf20e59c57d6a9474cf", size = 14972, upload-time = "2026-05-11T17:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/39/f2db2ff475d42222c70fe25c737028aeaafdd9c0aeba04e6b2dc66e7f93f/cmeel-0.60.1-py3-none-any.whl", hash = "sha256:3f92b68353a58b4d6b5a664ea96bf58a3fef0891a8ed570d3c153361bbbb94b7", size = 20612, upload-time = "2026-05-11T17:13:55.812Z" }, +] + +[[package]] +name = "cmeel-assimp" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-zlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/57/444bedd35567a8d6b61850b03aadf6b0d2d20737ee79c620c0e40f9a56dc/cmeel_assimp-6.0.5-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:840ab4bd398abbec5a612a731bb6f7fdc4e21b2708ae0dc0f99d7ff6d9992624", size = 10057855, upload-time = "2026-05-20T16:36:19.775Z" }, + { url = "https://files.pythonhosted.org/packages/6f/96/d4d128e074f59b79e90c78b8379127bd8953c72d1ad8a65229c23a09814f/cmeel_assimp-6.0.5-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d81f913e9efc8526a335900325906b85dc57891eeb7ece463714a107f14f63f", size = 9216472, upload-time = "2026-05-20T16:36:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/77/37/11de8f263a5cde3cde4796e344ff034674bdbb7a2381f6b5303d49b24def/cmeel_assimp-6.0.5-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:db6fe58f8bd633cb05ab9f390c576a4e774927ed8c343083fad4f939e834bf6e", size = 13709230, upload-time = "2026-05-20T16:36:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/62/66/7f12b2f003671828a46ab35964aed0a34632fcba5638b4049bdd918cf342/cmeel_assimp-6.0.5-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7b5ee36f3027de9b1bd73bd4294b60fed1bbc6c1524f94f64f69f38c5a49bf03", size = 14840275, upload-time = "2026-05-20T16:36:28.311Z" }, +] + +[[package]] +name = "cmeel-boost" +version = "1.90.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/01/ab2ea5b1ceae6283eb68be8beac529f294c70ac49d40b7873b0b639a7784/cmeel_boost-1.90.0.tar.gz", hash = "sha256:747d6d932838df26f50cdcb5983fa7532cad1d9ccce46c9f9d8b27b20a115981", size = 4085, upload-time = "2026-05-20T15:19:13.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/30/e0f27bd54396fe4d3eb1657aadde8e49f012f0dae667014e5c2682985561/cmeel_boost-1.90.0-0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7f3e5b0ddb503337e7dc7f8a1200d3687bb24eb568c0cda71ddee12c8b77fbe", size = 30799110, upload-time = "2026-05-20T15:17:32.803Z" }, + { url = "https://files.pythonhosted.org/packages/36/09/5a89a5209c08c9e8d5a6d4a3bad9a7ae99f803bf714f89dea0b69f477359/cmeel_boost-1.90.0-0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3666ee08702d8192657ef1badc80e08cadf69eaa27a8798895b9cc6249ee5144", size = 30693284, upload-time = "2026-05-20T15:17:37.227Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/ab76aaf09bb9258ee115b9c8c939adf57fcf571cba4e59d2a83cbd170935/cmeel_boost-1.90.0-0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6fc2ef59aecb088fd0e86c74dbdf7ec8c2e4f56ded0c95af87d967489b6ca479", size = 35829730, upload-time = "2026-05-20T15:17:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/1f/cc/0498404b945ebfb658b8bfbb1efaee630519efcde6f6959ac91daa93e116/cmeel_boost-1.90.0-0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d8ca4a91a78976ac243e33b51027efe71b35739e5f611dbdd992c388d3538acf", size = 36163159, upload-time = "2026-05-20T15:17:48.309Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/3e8c134393001b5f7dcf8c8aa1673b2126b271e1d475541a1fe0c1ca8bac/cmeel_boost-1.90.0-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0881a638b53d19340bab8bcc21b108999b20a8ed3a4db0a761b53b8116ad551", size = 30799100, upload-time = "2026-05-20T15:17:52.887Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bf/3383e9bdf24176f9815c98f39a34901a9019a05b587d32d7e901e4fd5a91/cmeel_boost-1.90.0-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:853b2cfb491733d782ae163adc6378baaccd8dc9752870677498ccb0eaea125a", size = 30693292, upload-time = "2026-05-20T15:17:57.243Z" }, + { url = "https://files.pythonhosted.org/packages/d7/30/c19fdd54edbb1bef7d8c403752d7055ddbffcd757a947074517f3d18e414/cmeel_boost-1.90.0-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:653285a31001ad30a9b6fc95c0f86ee9f65b4ab5d868f649a2df3472db53d368", size = 35828987, upload-time = "2026-05-20T15:18:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/07/52/ff72d2e3950a09e9fe9714d5952597f695efd5efcdc388d3781cec1910f0/cmeel_boost-1.90.0-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14efe34db660c9aacb61247a7f9ae0ee6d7626ec9a3d89b8b27e62dba6fcaa4d", size = 36162775, upload-time = "2026-05-20T15:18:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/5c/a01c51d296f59bf36851e8d23387d7d09952c99ed9dbc26ed0c502ed05c3/cmeel_boost-1.90.0-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:506c315ff4b48c2e7d146787541319f22d9b63a96defba7f963e3402c5e5eca1", size = 30801862, upload-time = "2026-05-20T15:18:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3e/c6d597586bbef678f19c19f10b5b1f75203861d3acba41b2ec0cd3fca6d5/cmeel_boost-1.90.0-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34657195b40faf0d090a55ee736db4b69938241730b8786e4b3430e4487519de", size = 30695049, upload-time = "2026-05-20T15:18:15.845Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/3f590f6446afa13af6232c18020ed61c72418f94f060ae6cc30af28b6b7d/cmeel_boost-1.90.0-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0b9546ad7cdffd1d633bd878e8334adedb2efe099e72f965157e49c474cb463f", size = 35830172, upload-time = "2026-05-20T15:18:21.109Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/c4dd452193fc017e80a8b0abd03c4bcbf45cebb21b87006268062cf2df8b/cmeel_boost-1.90.0-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c306b9af69d13502a6598672a341deb4f549fa3eb8cfb363de190e7409919a4a", size = 36167681, upload-time = "2026-05-20T15:18:26.397Z" }, +] + +[[package]] +name = "cmeel-console-bridge" +version = "1.0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/13/2e9e9d23db8548aef975564055bdb4fb6da8a397a1e7df8cb61f5afebefb/cmeel_console_bridge-1.0.2.3.tar.gz", hash = "sha256:3b2837da7ab408e9d1a775c83c0a7772356062b3a3672e4ce247f2da71a8ecd9", size = 262061, upload-time = "2025-03-19T18:22:06.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/a7/527fa060e5881acb3b0a07bf1d803ccb831cb87739abb62b6bcd14f5aed3/cmeel_console_bridge-1.0.2.3-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:7aa19b2d006073a1fad55d32968c7d0c7136749e06f98405f4f73a71038a5c41", size = 21341, upload-time = "2025-03-19T18:21:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/bb/db/f8643a8766e8909e0dbfcda6191ca92454cf9a3fadd89be417db261601a1/cmeel_console_bridge-1.0.2.3-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c47d8c97cb120feed1c01f30845d16c67e4e8205941e3977951018972b9b8721", size = 21286, upload-time = "2025-03-19T18:21:57.984Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/9c79177152a220ab2e4ffa0140722165035f6a5c2abbed2912352bd7e7b9/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_17_i686.whl", hash = "sha256:cad9723ac44ab563cd23bf361b604733623d11847c4edf2a2b4ebd1d984ade09", size = 23740, upload-time = "2025-03-19T18:21:59.683Z" }, + { url = "https://files.pythonhosted.org/packages/92/65/5741de6f550fe701d0780546d97b283306676315a3e1f379a6038e8c0ab0/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:372942e9c44f681bfff377fba25b348801283aa6f3826a00e4195089bda9737a", size = 25762, upload-time = "2025-03-19T18:22:01.055Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/70e23c5570506bb39b56aa4d0f3a4a414e38082ddb33e86a48b546620121/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5bb1115ed38441b2396e732e10ec63d1e68445674f9f5d321f7985eb10e9aeef", size = 24477, upload-time = "2025-03-19T18:22:02.091Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/bfd5a255348902e39243ccc6eba693bce714b891cd3be5603a9bd50c6de5/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b8d084b797f592942208c2040b08e06b82f8832aa6c5e582ba6f1a4a653505b", size = 24970, upload-time = "2025-03-19T18:22:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/3ae074e9acb9e150a4d5d97f341c2064573cd5fe9e5af20ab58bf8c0020a/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:fb6753a9864217d969c4965389d66a476ac978136c03eadf1063b1619c359220", size = 24689, upload-time = "2025-03-19T18:22:04.084Z" }, + { url = "https://files.pythonhosted.org/packages/69/d0/321f74b7d4167a6c59bb7714a6899ba402d9fad611f62573b9d646107320/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9d446c0fc541413d8d2ceea3c1cfb9cbfd57938d6659c113121eca6c245caafe", size = 24404, upload-time = "2025-03-19T18:22:05.232Z" }, +] + +[[package]] +name = "cmeel-octomap" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ab/2fed2dbee13e4b39949591685419f1dbb691295e32a6bbbaf87edc005922/cmeel_octomap-1.10.0.tar.gz", hash = "sha256:bd79d1d17adede534de242e42e13ef0d9f04bdd27daf7d56c57f7c43670c9b05", size = 1694189, upload-time = "2025-01-06T17:57:05.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/22/ea67d35df31ec4bb2ed6e594b173c572c72dbd2a87e96906eac67b4af930/cmeel_octomap-1.10.0-4-py3-none-macosx_14_0_arm64.whl", hash = "sha256:c116eb151920d26ee2b2c1f656cd7526862006739817205f11f9366ab0ef6cb4", size = 639956, upload-time = "2025-01-06T17:56:56.826Z" }, + { url = "https://files.pythonhosted.org/packages/b2/da/07725a8c11224881f536ad252e97a3d9801b48e5e776017d5f00fb39b17f/cmeel_octomap-1.10.0-4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:76cc42553f54bae97584aaf0c7bc33753ff287e2738aa2ecac4820121101dd46", size = 1044402, upload-time = "2025-01-06T17:56:58.553Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/9617b7039afd6d17d3148f6f970d953f5e265d7736f8fdbca09c86e976a0/cmeel_octomap-1.10.0-4-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5fdb04546fff3accac5f8626c3fc15c3b99e94ab887793565e0b92cedaf96468", size = 1105037, upload-time = "2025-01-06T17:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/88c1d1eca1abf2387ee8263ac7e12708c8b1b5b70b46a0bd9f43b485165b/cmeel_octomap-1.10.0-4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:84a7376cfced954bb7e3e347afbd02bdc1c83066b995afbdd0fb1e2d9f57ebec", size = 1108359, upload-time = "2025-01-06T17:57:04.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/59/57b3b38cf7a382855902b9d24266c283c29d977706438e6b7af62df74e2b/cmeel_octomap-1.10.0-5-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:042b4a21b5e5e19ee78a9a7db78e1b06fb8a287c832031788aec0d3fcabbfecd", size = 748832, upload-time = "2025-02-12T11:57:34.252Z" }, + { url = "https://files.pythonhosted.org/packages/55/8b/f5ec7676808a48c0185e216c0da700e34cb13ba233f13a4557a5ec56324a/cmeel_octomap-1.10.0-5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:79c15a0ece5ca3746170088ef2a377dfb3df8326fafde9bdba688852219758b9", size = 706924, upload-time = "2025-02-12T11:57:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/09/50/56de5a4d9f8ca58100146f16f42c4e2fbb49c0957bfe40d3fd2bc910afe4/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_17_i686.whl", hash = "sha256:e2923bf593ebdafed86b6f3890a122c62fbd9cc9f325d60dbecb72b6b60d78fb", size = 1073973, upload-time = "2025-02-12T11:57:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/8dddf5cdd31176288acd85cc8bf0262b7c3de81d5cb2cb33aa6646f44eb1/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9e6f9c826905e8de632e9df8cc20e59ce2eb5d1e0b368d8d4abbbc5c0829c1a", size = 1044533, upload-time = "2025-02-12T11:57:39.672Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/b85bd33bb05c9bb7e87b9ac8401793c12a80a6d594b3ca4bcb5e971a24b7/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b0b54fac180dce4f483afe7029c29cc55f6f2b21be8413e8e2275845b0c204d7", size = 1105199, upload-time = "2025-02-12T11:57:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/fe3360441159974ebdbb4c013a92ad0425d5f8bf414868d5161060e40660/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c8691e665bab7c12b6f51e6c5fbbb83ee6f91dce9d15d9d0387553950e7fb5ee", size = 1092962, upload-time = "2025-02-12T11:57:43.297Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/074166544cc0ce3a5d7844f97dfd13d1b3ec7bff6a6e2cfb18d66a671a7f/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:735c0ad84dacbbcc8c4237f127c57244c236b7d6c7500b5c45a4c225e19daac1", size = 1083321, upload-time = "2025-02-12T11:57:46.121Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a3/b19ea0d30837369091141b248936b0757ee17f58b809007399bad0b398e4/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f86a83f6bd60de290cd327f0374d525328369e76591e3ab2ad1bc0b183678c4", size = 1109207, upload-time = "2025-02-12T11:57:48.709Z" }, +] + +[[package]] +name = "cmeel-qhull" +version = "8.0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/dd/8d0bcfb18771b2ea02bf85dfbbc587c97b274496fb5419b72134eb69430b/cmeel_qhull-8.0.2.1.tar.gz", hash = "sha256:68e8d41d95f61830f2d460af1e4d760f0dbe4d46413d7c736f0ed701153ebe52", size = 1308055, upload-time = "2023-11-17T14:21:06.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b4/d72ebd5e9ee711b68ad466e7bd4c0edcb45b0c2c8a358fdcdb64b092666a/cmeel_qhull-8.0.2.1-0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:39f5183a6e026754c3c043239bac005bf1825240d72e1d8fdf090a0f3ea27307", size = 2804225, upload-time = "2023-11-17T14:15:39.958Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/4bfb8d51a09401cf740e66d10bdb388eacd7c73bae12ef78149cbbc93e83/cmeel_qhull-8.0.2.1-0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:f135c5a4f4c8ed53f061bc86b794aaca2c0c34761c9269c06b71329c9da56f82", size = 2972481, upload-time = "2023-11-17T14:20:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7c/74b5c781cbfc8e4a9bb73b71659cc595bc0163223fd700b18133dbcf2831/cmeel_qhull-8.0.2.1-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:17f519106df79aed9fc5ec92833d4958d132d23021f02a78a9564cdf83a36c7c", size = 3078962, upload-time = "2023-11-17T14:21:00.183Z" }, + { url = "https://files.pythonhosted.org/packages/b4/16/ef7b6201835ba2492753c9c91b266d047b6664507be42ec858e2b24673b5/cmeel_qhull-8.0.2.1-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:c513abafa40e2b8eb7cd3640e3f92d5391fbd6ec0f4182dbf9536934d8a8ea3e", size = 3194917, upload-time = "2023-11-17T14:21:01.879Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ae/200bdf257507e2c95d0656bf02278cd666d49f0a9e2e6d281ea76d7d085c/cmeel_qhull-8.0.2.1-0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:20a69cb34b6250aee1f018412989734c9ddcad6ff66717a7c5516fc19f55d5ff", size = 3290068, upload-time = "2023-11-17T14:21:03.828Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/de3fa6091ef58ab40f02653e777c8943acf7cec486184d6007885123571d/cmeel_qhull-8.0.2.1-1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b5d47b113c1cb8f519bc813cf015d0d01f8ce5b08912733a24a6018f7caa6e96", size = 2902499, upload-time = "2025-02-12T11:51:16.999Z" }, + { url = "https://files.pythonhosted.org/packages/05/0c/5e5d9a033c683eb272508ccf560c03ac6bf5d397b038fe05f896a2283eaf/cmeel_qhull-8.0.2.1-1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:33a0169f4ee37d093c450195b0ef73d4fe0d9d62abb7899ebe79f778b36e1f36", size = 2773563, upload-time = "2025-02-12T11:51:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/52/9b/00c73069348e60fbbdf6a5a10de046083f7d1ad36844958bbf12163ac688/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_17_i686.whl", hash = "sha256:a577e76ac94d128f2966b137ead9f088749513df63749728e2b588f4564b7fdf", size = 3228684, upload-time = "2025-02-12T11:51:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/81b8c88b444935a64d8c83b41e662f696c36dd5937c3ca687113ac4778d0/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fd0b2d4ce749b102c3cdead4588249befd34f1a660628f6bfc090ce942925aac", size = 3156051, upload-time = "2025-02-12T11:51:24.594Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c1/44874cd8bfc1e3f7cb15678c836c7a1d5537f34f5a727a0207e01f395598/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2371a7c80a14f3e874876359ae3e3094861f081fcdd7a03987c3e880d14e07b9", size = 3262508, upload-time = "2025-02-12T11:51:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/54/0e/425d9ce1f2a831025d39fa5b6479b856bd4d73614c9caa690ac72bbfca04/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:197c14c2006dbeba8f5a5771700a7afea72c1a441aab7cdeaaf10b4ed8c1137d", size = 3172646, upload-time = "2025-02-12T11:51:28.967Z" }, + { url = "https://files.pythonhosted.org/packages/00/c1/e973e287a7d793911b8e6497b17586e601a678f2379ba2c615f72bd76480/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:886d1be24b31842286ae42755af5c312a43a4199632826e4110185ec36dc5c6a", size = 3530837, upload-time = "2025-02-12T11:51:31.651Z" }, + { url = "https://files.pythonhosted.org/packages/fd/65/c6cd54f04b5fcaa4ec52f5b57692c1dcef812ff9ee86545e5607369d365e/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a49ce7f8492c9a8b49f930e34cce75b5e9b9843b015033dd0a25421441159fc", size = 3301908, upload-time = "2025-02-12T11:51:34.53Z" }, +] + +[[package]] +name = "cmeel-tinyxml2" +version = "11.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/96/4311533fee0a364bb605b585762f04c249f47857b33548a8ea837a7eb860/cmeel_tinyxml2-11.0.0.tar.gz", hash = "sha256:85d9c7680b3369af4c6b40a0dce70bbd84aa67832755622e57eb260cd95abe40", size = 645900, upload-time = "2026-05-21T11:49:32.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/f0/90c1640c53b623359d75ab1c70bdf19dc0afe82722bc5df57d09f8eaf83a/cmeel_tinyxml2-11.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b0bd974e549b8c444626671a8e645897603ebf5225734cbe04a9dd3461477754", size = 111719, upload-time = "2026-05-21T11:49:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/56/40/166447150a31bc3b794ffb493d5a634f67ffbc75dd8b4c46373701b7ef15/cmeel_tinyxml2-11.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a1406f408262c37ae7c4566b1d67801c4b10c4980903fb1ef0ba45fa4407072", size = 109146, upload-time = "2026-05-21T11:49:27.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ca/3cc665afe2d76999f15454bb3b2f7c05f0088ad7de35718648291a536fd9/cmeel_tinyxml2-11.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6f830007917c3e36f26b27d170ce84a619a62f46104d3cce435dff0125dd665f", size = 157109, upload-time = "2026-05-21T11:49:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/87/4e/dcc0d9756d93be734d824e2a570cc9ac68909a1d7d3b6fc87c2fb32726c0/cmeel_tinyxml2-11.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:18674156bd41f3993dc1d5199da04fa496674358daa6588090fb9f86c71917b0", size = 148825, upload-time = "2026-05-21T11:49:31.035Z" }, +] + +[[package]] +name = "cmeel-urdfdom" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-console-bridge" }, + { name = "cmeel-tinyxml2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/75/4e8aff079e98582aeeb8e752805081da0c2dea405e79bafeefb555defe9f/cmeel_urdfdom-6.0.0.tar.gz", hash = "sha256:65c0fdc6021300fc55b2d0c03ab64dedc328034a74e40498e671bc894bb1dcf7", size = 303688, upload-time = "2026-05-21T12:08:56.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/40/51ba667135f01631179eee1614557193f8453740f248302d1b8b7f9f693e/cmeel_urdfdom-6.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:53d55cebb137a6e4dac6c16fa53f2dc2b7b9b5cda644bd1637a5bb849cd96e52", size = 381501, upload-time = "2026-05-21T12:08:48.758Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/2b49a8c940fa75abc13df9842c14e577e6a82d5854b6d52597ce3bb04894/cmeel_urdfdom-6.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0ef424735bd30f4afa4d1b4ddca9b297498c43005ddd775c080e55f62e9e0466", size = 377159, upload-time = "2026-05-21T12:08:50.485Z" }, + { url = "https://files.pythonhosted.org/packages/db/ac/0efde3a48220b55707bafb6d2e2dcca562f99dcd5c2c15311f7696eeacce/cmeel_urdfdom-6.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0436f5230f1484c8e583284ef48c7291b230ada3dc5fb2937941f582e72409ec", size = 506000, upload-time = "2026-05-21T12:08:52.273Z" }, + { url = "https://files.pythonhosted.org/packages/32/d4/dfd617e598100e4e53ae3d228a968facff80bae53038fb18e2dccb1ab03a/cmeel_urdfdom-6.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7ab1be680a8ec866d5422c617b641d1f0e38774061df28b8b426fb26edce6337", size = 530049, upload-time = "2026-05-21T12:08:54.224Z" }, +] + +[[package]] +name = "cmeel-zlib" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/85/9c6b77d5c49b363b17ba974271d58730bd26cbe00b1c576596339bb624ea/cmeel_zlib-1.3.2.tar.gz", hash = "sha256:6e31f2956c334de9a7e19a4e4f8eed030ed8c8016c841ea57226ce8bed443712", size = 3200, upload-time = "2026-05-20T16:24:37.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e7/c60533fb6f638fe92d56b79edbcff70ed74a7f19b3ace981532efab5c3dd/cmeel_zlib-1.3.2-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:88a89882340391aa263ed1f7448fe0177e6fb9b93740cad381228437879f16a9", size = 1035696, upload-time = "2026-05-20T16:24:28.538Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/dfcc8b9c2989e7342aaac1781d147b06b31197337d1968029216be26ee43/cmeel_zlib-1.3.2-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:94a2f1adcc811617eab849b5771e608fa20e2fc44fb8cbd2e56a3e4d496461c1", size = 957505, upload-time = "2026-05-20T16:24:30.151Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e9/6a3d6732e23fcf7072973f9ca8251eaef3e6738e46d6846064dd92e13331/cmeel_zlib-1.3.2-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3b07fb7e28aaa917e6accb59a29dd0f5c1d272d42988f32ba2c232f5f455be4c", size = 1055975, upload-time = "2026-05-20T16:24:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/70/a4/d345605b6f8c9d665baa1dd2f839ad6aee0d771e174be12c81fdd53067ed/cmeel_zlib-1.3.2-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4658000c5531273d14ca8f0250abcd2a2ad85b336f10a273a77ecb38611a5f5a", size = 1052274, upload-time = "2026-05-20T16:24:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/83737c38cc106e1b4959393da7744d5180b9ab5eac0369fc6ba0ea6c6428/cmeel_zlib-1.3.2-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:06cbf60a361ca18e8a4c46a238149cebcaf955047a60830705e130aa397b5116", size = 1057248, upload-time = "2026-05-20T16:24:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/b378182b0f33ed9e9c091b3d1e8d86a6f2d69add107087b2e09adcb12137/cmeel_zlib-1.3.2-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2a3425a65a0adf0ddbb48d41204a0d9c13197bd693f7821d1450e8a969d77352", size = 1055409, upload-time = "2026-05-20T16:24:36.219Z" }, +] + +[[package]] +name = "coal" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-assimp" }, + { name = "cmeel-boost" }, + { name = "cmeel-octomap" }, + { name = "cmeel-qhull" }, + { name = "eigenpy" }, + { name = "libcoal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/14/8cb072a3e5fb85cac4d8d1234351ca3eb4ecf100b87901ab912ec7e5135e/coal-3.0.3.tar.gz", hash = "sha256:18660bde4021af496343646fa0a0d1bcf0be392f432641e772dc423be5075f64", size = 1464421, upload-time = "2026-05-21T12:30:20.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/2a/aa46eb1568f2d6e8a121f0da15a7778e2156058cf141d646a69c46d35bc5/coal-3.0.3-0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f28ec989f385b9ffa5a0e2457efbc54d37fc70a6fa9df8f8caeeb12e8a7f8296", size = 1611143, upload-time = "2026-05-21T12:29:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a8/43c3d209ae76ae0ba78dc3398af1ad5f817832478e20652c258d5bfb2ce8/coal-3.0.3-0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da9ae72679d17a675be2f204debe60823b6568c1817670788f55cf666d7389a3", size = 1505259, upload-time = "2026-05-21T12:29:47.449Z" }, + { url = "https://files.pythonhosted.org/packages/c4/55/a909441bec811dfbc1ab453cac9eb26d5fdba18349348625ea33ec49b48b/coal-3.0.3-0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6641e1b9de4a10d7986749751c4507f9b1708e438621e361c512037e7d249ad2", size = 2218416, upload-time = "2026-05-21T12:29:48.889Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ca/3f92a4a4ccd1392d0cfc3f6019241ea4939ff8b5b59e2b2dd0e7f1819330/coal-3.0.3-0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:2f526bb6173f5a4aab5cb0485a99fed73e6179b11814272a41f9e787cd509ed6", size = 2216341, upload-time = "2026-05-21T12:29:50.499Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4d/6af55fff20a0705f54ea313c2fe64b090e3e60e50fdcb5bfdb430a22e391/coal-3.0.3-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1e59370db33b4fb0e23da8f953b300b3eec591624803e3143d447be21f530b87", size = 1611149, upload-time = "2026-05-21T12:29:52.062Z" }, + { url = "https://files.pythonhosted.org/packages/9a/78/3323984a61b63515e1951e2f697d4250a55b54788bc65496e2824989d969/coal-3.0.3-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:22572a8f877488ab987f868ffd362c08f923470be6b1e4f9c86d260f110405fc", size = 1505271, upload-time = "2026-05-21T12:29:53.871Z" }, + { url = "https://files.pythonhosted.org/packages/1d/29/65550bafd2639b8152e6a0e63603e74f657761fc1dc5daa4a305b5ea9922/coal-3.0.3-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b8aafdf317161ba73858ed1a3e8e673c5a82bd04cef82a3b7ba953d6a8d1007d", size = 2218087, upload-time = "2026-05-21T12:29:55.361Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/e5859f9c03c6353e50ac8df218a6b9efb8de90f3fa1f3bd571bc6a3242cd/coal-3.0.3-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2c9a62cd52868ce295623e34e664084ee87b9bb6314df913952d288cbe8b3fe6", size = 2215823, upload-time = "2026-05-21T12:29:57.227Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/1c634b366c7ba991f3f11b73bfff5c8cfee0ee32911347f37a256bc1724c/coal-3.0.3-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fd9c9bb7d4de17afda5e59802fddddee4149bdf72312cd60d68403b2d5265e9b", size = 1626797, upload-time = "2026-05-21T12:29:59.079Z" }, + { url = "https://files.pythonhosted.org/packages/33/5a/6c9836db4747010fc31543336a6b7f9aea04b1a58b8fe09611daf5e965f4/coal-3.0.3-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d3c157a72d3c79a99bd6b59479ab5ca0efeed22b74e5d8e66dda827ce76e18e", size = 1516483, upload-time = "2026-05-21T12:30:00.967Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/8c7c7e83c83acdc5af8c553a2ca0b1b335132dccff4732edd6acd90a5a16/coal-3.0.3-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f9736086902db07b3b38b04917e7d3c9291d12340e5248c06b327ce6a6e2de49", size = 2190068, upload-time = "2026-05-21T12:30:02.692Z" }, + { url = "https://files.pythonhosted.org/packages/19/f1/25368a2fb63a18283b6732e2cdbe15bfb19df26b76f3721fd79729def54a/coal-3.0.3-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b98f45b92af8f6608a97d47f05abab861af13ba54a47c82dfc22c9f4e7754a18", size = 2201644, upload-time = "2026-05-21T12:30:04.333Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "configargparse" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "daqp" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/2d/fd713bbd3660d5c5306599dd138a341ff70cff246c7ccffc7a8d586e7004/daqp-0.8.7.tar.gz", hash = "sha256:62cfd3208a9841ffb6a87d145bce930a0e87ecea802a5ffed5d0146c4b133a54", size = 37292, upload-time = "2026-05-19T17:21:44.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/00/97043ee6f2b13a63f0b59f776055dc199621ca8773f9d627fae3d2bfabe6/daqp-0.8.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:67e99835efe68e3ba1bc721074e726081e65bfc51f5a81b6ac9407979ad4d33e", size = 166267, upload-time = "2026-05-19T17:34:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/4947398babec3a517f43e10555d2d37f8dea19dbc9a09bb398080088e3ac/daqp-0.8.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:de5540765884e56d0fb8bf945a8d500aa9ca268b506535c4dac37ff48c8bf9d5", size = 156307, upload-time = "2026-05-19T17:34:19.415Z" }, + { url = "https://files.pythonhosted.org/packages/bd/af/a8e544f8e9dd143dbc0cb2afdbd0221f2a272dd662323ab1243857269d5e/daqp-0.8.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2307d8e96417c6965e4bd6e7857e80d1ca0c52bec2cba13173dec736252d52c4", size = 838020, upload-time = "2026-05-19T17:21:32.219Z" }, + { url = "https://files.pythonhosted.org/packages/80/a5/c80745b09d54eb83ddef70dacc74499360395f3d9f7cbba8bfc95165ed40/daqp-0.8.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4be0d4256100a4fd35d01907ae1d4ad164e8a152707e7ddfebe386b082652242", size = 776438, upload-time = "2026-05-19T18:15:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/0c/06/d11d5bb0ad9d14ee2b0db09eeba796ea56168c7ce20494e22a5f25482c5d/daqp-0.8.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62dd2c6d14c8baefbc489ae6ba178debf36388eebd12bfa6fefed0599675fc69", size = 802887, upload-time = "2026-05-19T18:15:20.662Z" }, + { url = "https://files.pythonhosted.org/packages/a0/dd/2a12b44e054c4e416ac27b0e115c518cec79c7efe62f5306b12113815657/daqp-0.8.7-cp310-cp310-win32.whl", hash = "sha256:97ba8d791ae9fa27233d2e35458753e51e6868f8bb69a969db2f5a58845b26a6", size = 112174, upload-time = "2026-05-19T17:28:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/a3/97/c790956cfda45fa350a7edcc04e52d91807af6872a87d8887db15140a29b/daqp-0.8.7-cp310-cp310-win_amd64.whl", hash = "sha256:dfcf324bb1b1092c2732d6ec1080cf526a52a915a52aa2a0161e874dc6c2d57e", size = 135871, upload-time = "2026-05-19T17:29:00.191Z" }, + { url = "https://files.pythonhosted.org/packages/ce/db/014f8c50341f6bf44c283759fc003c0241019db049f29d2a894d4fab02a8/daqp-0.8.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ab5029cb1d8f03b5e89605db911341b8ca5dffdc94c7731a416a2367781e12a", size = 164683, upload-time = "2026-05-19T17:34:20.6Z" }, + { url = "https://files.pythonhosted.org/packages/b2/31/ba8e9683f6adec04fc4f8a12097fa60cfbcda614aefa4f76c50dc0caf807/daqp-0.8.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e8cd3abd087de80787bcf08198254b62b67dfbceb528f33272b264a6ef5f4c9e", size = 155026, upload-time = "2026-05-19T17:34:21.678Z" }, + { url = "https://files.pythonhosted.org/packages/28/77/12713db97342bc60295de0897974d80b4b2c94677551e66dcc91b455c90b/daqp-0.8.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17ba0800e38853406a27293ef61b6b638e4194f6edefc1470e4b8fc8c93621ab", size = 871589, upload-time = "2026-05-19T17:21:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a2/bccc1d5c62d22d97f4fa7884e2953aa69f69c828b99049ad3f827ba35f86/daqp-0.8.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b65244b8823b916af8e52fa83d761d1e422cdf8d024d5a9b1bfce87ddf6626de", size = 814130, upload-time = "2026-05-19T18:15:22.032Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9b/b48f9e965c30dd34e3cb1e82379443d2af52fb185e0e50036683e5639227/daqp-0.8.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6fb318e3057bad2ca2feb5b9e71e1c7354039440de25ab09b7532187e00c804f", size = 836463, upload-time = "2026-05-19T18:15:23.444Z" }, + { url = "https://files.pythonhosted.org/packages/07/52/4edfd855df621a2d9eed6f0790c29dac37702abd5d19ac2e01486c65af69/daqp-0.8.7-cp311-cp311-win32.whl", hash = "sha256:92e6f7e91429cba5048748468eb9e6bb207181464362e4e92c6ab89571942ed5", size = 111692, upload-time = "2026-05-19T17:29:01.118Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c0/2ff65e53b5d5f536e9bedefa17b2dc49c3c501224840557dd9e6274c09c2/daqp-0.8.7-cp311-cp311-win_amd64.whl", hash = "sha256:8d658d0893ac42ed1b64e9f7fc2b97805274b11aafbe4702b20029fe74f33d25", size = 135758, upload-time = "2026-05-19T17:29:02.296Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4b/30d87e9bad79e5455d113e7b62f96aa19032669b8ee90e7c96856540e008/daqp-0.8.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4f3667c651af74ac93116bd8391f6541bcec0eb3aeada39db20a6eb8db000f5b", size = 159925, upload-time = "2026-05-19T17:34:22.551Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8a/2bf8fa99cf21f398def0dbc40b29a6c2130a5569ee13530f38276c84c091/daqp-0.8.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:deed3e39b8d52fd3ac801c79a0dffbd46698a7b6cc9bf90267946d31dd87db84", size = 152041, upload-time = "2026-05-19T17:34:23.551Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2b/ce4c2af256fd79b041e9c177285fc9014b3921a942a6a4cbe3246151b8a4/daqp-0.8.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd08316be8d1be489278085712bc09ca15e3b6d34eb7efc462aa43c0069a4b63", size = 870393, upload-time = "2026-05-19T17:21:35.176Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f5/5911ca559783148214071f787a8c61c63ec6f967f8b9ea6efc3276440bb5/daqp-0.8.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebe76e0e556b0817528b342f1fe560eb62aa78fd028dbefe6f300fa6e0ff4445", size = 800928, upload-time = "2026-05-19T18:15:24.905Z" }, + { url = "https://files.pythonhosted.org/packages/db/f9/e3e7450659ed519ba090a43b364e08008a8857c3f534dc262d32025a5de0/daqp-0.8.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c31d1e9035bb8c4bf2ca1be907e1ba579bb1920cc874a97877b11ff822c9460", size = 816350, upload-time = "2026-05-19T18:15:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/28/0c/7ba9e41d7145907556b0ba711aaaa2e6976d7d7b3cab73584607acac3017/daqp-0.8.7-cp312-cp312-win32.whl", hash = "sha256:3a18333d7b5112b21116d8600645ceaa766bc37efada78e98ba4490e28cf4d4f", size = 108497, upload-time = "2026-05-19T17:29:03.354Z" }, + { url = "https://files.pythonhosted.org/packages/37/5f/7ebf43ce72bfb990b10675425d7c8f7eefa37f38264a50df12f0c54520ae/daqp-0.8.7-cp312-cp312-win_amd64.whl", hash = "sha256:ac3f04950b80cc72fd019cd133e23178941b6ab0166e31370d4903757c43b51c", size = 131792, upload-time = "2026-05-19T17:29:04.485Z" }, +] + +[[package]] +name = "dash" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "janus" }, + { name = "mcp" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/3c/0608ea83920ddbd27de3aeb60d48e56efb3390c2cfb44dabfd8dc25c0ba2/dash-4.3.0.tar.gz", hash = "sha256:1ec96641d5e3c06b7dfffad8241e345533d70327d96f722a8e13b827c287341b", size = 8095642, upload-time = "2026-06-19T14:56:03.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/ad/0a230dc6cd85bb38e7fe6acc9fbaeb19ddf16ababcb54038ae81128da591/dash-4.3.0-py3-none-any.whl", hash = "sha256:cbbe79a2bb1a5e46fbbbddc33f9f558419442c2d55cd12526078f489b5801cc0", size = 8445443, upload-time = "2026-06-19T14:55:59.024Z" }, +] + +[[package]] +name = "dbus-fast" +version = "5.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/69/9235af850e2ee0cec23b4e863612492964a9c40b65f3002e4a9f32e1ffa2/dbus_fast-5.0.22-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8244561e503cffcd5e6b1ff6544a44aa0b65b6856f2f64d04345ebf82f8ac48", size = 840236, upload-time = "2026-06-05T18:55:41.007Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d1/08ed17331dd23a8b91b80db9acca48301c4879352024dc7882e2c5893fa2/dbus_fast-5.0.22-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32869678240f233ece5a1137960c0337699dbb39acc02c155929f52543f03fb", size = 884309, upload-time = "2026-06-05T18:55:42.447Z" }, + { url = "https://files.pythonhosted.org/packages/34/ed/02458af54460e55cf26fdf0a778aa9b3217ac8d37e6280aa68e19f176650/dbus_fast-5.0.22-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f74700b1b4fd58acb76f8790e2cd475084d0e59149e0cc035abd239435a339b7", size = 885179, upload-time = "2026-06-05T18:55:43.801Z" }, + { url = "https://files.pythonhosted.org/packages/e9/79/4958c807e59106558a4415212e6be019775f5be9775ffc79c6bc7248b388/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:246780dcda59d26dd2fb1d9a15991bc8483e9c241cee8713920a39a363ad4d39", size = 847693, upload-time = "2026-06-05T18:55:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/a5322fbc172ee099f98186224cf7e3bf48fc8bcb08270951da8006b556ba/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:58e4d41f9fea22cfd6dacdf03d53b932bbe15a825af5abad3ca79b91493a15c3", size = 882773, upload-time = "2026-06-05T18:55:46.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/e068899c1d9a034ed9d5fc8519b8e2247aa8c28a6a67229162db64163b59/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d8ca39f3f2ab34f88bb343db87c7cf8b07eefa046b23d8c38b3387f150ac609e", size = 891573, upload-time = "2026-06-05T18:55:48.34Z" }, + { url = "https://files.pythonhosted.org/packages/ec/84/dfb014de75a3a854dccaae1cce8f840e4312e3efc781768eedd60d25d9ef/dbus_fast-5.0.22-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846f9a6602b4383f989201f7851459fb225a8912cd24b38e63894748545c3040", size = 838836, upload-time = "2026-06-05T18:55:51.51Z" }, + { url = "https://files.pythonhosted.org/packages/70/c2/be41bcc678e97092d44ba22d09ce687f76c955b3367a7e6863377b1cfea5/dbus_fast-5.0.22-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886b43446b6fdc3986befbbb88db1365b14e49dd0a7edf84c2c67ac66c7160a4", size = 883163, upload-time = "2026-06-05T18:55:53Z" }, + { url = "https://files.pythonhosted.org/packages/17/cf/336c08f88fdd813a39fc1603a10a15aec67115e08a895e7c9840df54d4d7/dbus_fast-5.0.22-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a699fca957acc845ddb12b47f741dba23ce147fdb93583e0c7e7bad3e9b2355", size = 886852, upload-time = "2026-06-05T18:55:54.434Z" }, + { url = "https://files.pythonhosted.org/packages/04/f1/7c1aa53f25252a2317f21ad6e8eaa125246d2585ea4611f3f10a6feaeeb1/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a5b05fd4973862042e5bee2c5e8c5a15297e0b33a975bf25b44becf7bcb3618", size = 846497, upload-time = "2026-06-05T18:55:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/4c/32/a981ef2305f1bf41e538e02fa0cd69614f9043fd00ca965bf3044416c79b/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6c4dae5292a7924ec062815c34b49043d8386cd22e165f9fb4012de00997cdf1", size = 882275, upload-time = "2026-06-05T18:55:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/78800a172f4ca32e19a70a36e175f54831f33a497c9644f3a3fa4dce01ea/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7d90a52be79acbaef257f3a81d5b9b9dec40f1bad29429ac5c7802684fb9b84", size = 890566, upload-time = "2026-06-05T18:55:59.407Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/233b0bc13919474f70320bf389cb81ce02811956d6cf86c45e84679b63c3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca", size = 799464, upload-time = "2026-06-05T18:56:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/68/e9/77bc23a6f5aebfb8f2c34489795e8517aed7eca31738438e1a4c4a4891d3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", size = 852687, upload-time = "2026-06-05T18:56:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/56769d0936d1273d1801ef574ec426ccb3f61f4b0a7a0eeb9eb2b8ccafa5/dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c", size = 833814, upload-time = "2026-06-05T18:56:05.87Z" }, + { url = "https://files.pythonhosted.org/packages/06/ca/964f0d39a3be03b12a98f39519d34ad95b74360c7e3adf4cd4907dc25fd6/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b013437b66dc22b8d9aca5e0b0d46bf1980208a143409469fe482d9684a2a717", size = 806891, upload-time = "2026-06-05T18:56:07.623Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/57fe6ab509ad9da2e190498fa9c37f868e38ac521d940a9edb9ba6b6c657/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:855f15b7f7805171da2b82de1c317d01cfbb9fb8ac61fcc1e8dec54d8c69fab7", size = 830867, upload-time = "2026-06-05T18:56:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" }, +] + +[[package]] +name = "dimos" +version = "0.0.13" +source = { editable = "../../" } +dependencies = [ + { name = "bleak" }, + { name = "cryptography" }, + { name = "dimos-lcm" }, + { name = "dimos-viewer" }, + { name = "llvmlite" }, + { name = "lz4" }, + { name = "numba" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "open3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "open3d-unofficial-arm", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "opencv-python" }, + { name = "pin" }, + { name = "plotext" }, + { name = "plum-dispatch" }, + { name = "protobuf" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "pyturbojpeg" }, + { name = "reactivex" }, + { name = "rerun-sdk" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sortedcontainers" }, + { name = "sqlite-vec" }, + { name = "structlog" }, + { name = "terminaltexteffects" }, + { name = "textual" }, + { name = "textual-serve" }, + { name = "toolz" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "websocket-client" }, +] + +[package.metadata] +requires-dist = [ + { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'" }, + { name = "bleak", specifier = ">=3.0.2" }, + { name = "can-motor-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'", specifier = ">=0.0.2" }, + { name = "catkin-pkg", marker = "extra == 'grasp'", specifier = ">=1.1.0" }, + { name = "chromadb", marker = "extra == 'perception'", specifier = ">=1.0.0" }, + { name = "cryptography", specifier = ">=46.0.5" }, + { name = "cupy-cuda12x", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = "==13.6.0" }, + { name = "cyclonedds", marker = "extra == 'dds'", specifier = ">=0.10.5" }, + { name = "cyclonedds", marker = "extra == 'unitree-dds'", specifier = ">=0.10.5" }, + { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "grasp", "learning", "manipulation", "manipulation-toppra", "misc", "perception", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, + { name = "dimos", extras = ["agents", "web", "perception", "visualization"], marker = "extra == 'base'" }, + { name = "dimos", extras = ["base", "mapping"], marker = "extra == 'unitree'" }, + { name = "dimos", extras = ["unitree"], marker = "extra == 'unitree-dds'" }, + { name = "dimos-lcm", specifier = ">=0.1.3" }, + { name = "dimos-viewer", specifier = "==0.32.0a1" }, + { name = "dimos-viewer", marker = "extra == 'visualization'", specifier = "==0.32.0a1" }, + { name = "drake", marker = "platform_machine != 'aarch64' and sys_platform == 'darwin' and extra == 'manipulation'", specifier = "==1.45.0" }, + { name = "drake", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and extra == 'manipulation'", specifier = ">=1.40.0" }, + { name = "edgetam-dimos", marker = "extra == 'misc'" }, + { name = "einops", marker = "extra == 'perception'", specifier = ">=0.8.1" }, + { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.115.6" }, + { name = "faster-whisper", marker = "extra == 'agents'", specifier = ">=1.0.0" }, + { name = "ffmpeg-python", marker = "extra == 'web'" }, + { name = "gdown", marker = "extra == 'misc'", specifier = ">=5.2.2" }, + { name = "googlemaps", marker = "extra == 'misc'", specifier = ">=4.10.0" }, + { name = "gtsam-extended", marker = "extra == 'mapping'", specifier = ">=4.3a1.post1" }, + { name = "h5py", marker = "extra == 'learning'" }, + { name = "hydra-core", marker = "extra == 'perception'", specifier = ">=1.3.0" }, + { name = "ipykernel", marker = "extra == 'misc'" }, + { name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.6" }, + { name = "langchain", marker = "extra == 'agents'", specifier = ">=1.2.3,<2" }, + { name = "langchain-core", marker = "extra == 'agents'", specifier = ">=1.2.22,<2" }, + { name = "langchain-huggingface", marker = "extra == 'agents'", specifier = ">=1,<2" }, + { name = "langchain-ollama", marker = "extra == 'agents'", specifier = ">=1,<2" }, + { name = "langchain-openai", marker = "extra == 'agents'", specifier = ">=1,<2" }, + { name = "lap", marker = "extra == 'perception'", specifier = ">=0.5.12" }, + { name = "llvmlite", specifier = ">=0.42.0" }, + { name = "lz4", specifier = ">=4.4.5" }, + { name = "matplotlib", marker = "extra == 'grasp'", specifier = ">=3.7.1" }, + { name = "matplotlib", marker = "extra == 'manipulation'", specifier = ">=3.7.1" }, + { name = "mcap", marker = "extra == 'unitree-dds'", specifier = ">=1.2.0" }, + { name = "moondream", marker = "extra == 'perception'" }, + { name = "mujoco", marker = "extra == 'sim'", specifier = ">=3.3.4" }, + { name = "numba", specifier = ">=0.60.0" }, + { name = "numpy", specifier = ">=1.26.4" }, + { name = "ollama", marker = "extra == 'agents'", specifier = ">=0.6.0" }, + { name = "omegaconf", marker = "extra == 'perception'", specifier = ">=2.3.0" }, + { name = "onnxruntime", marker = "extra == 'cpu'" }, + { name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = ">=1.17.1" }, + { name = "open-clip-torch", marker = "extra == 'misc'", specifier = "==3.2.0" }, + { name = "open3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=0.18.0" }, + { name = "open3d-unofficial-arm", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = ">=0.19.0.post9" }, + { name = "openai", marker = "extra == 'agents'" }, + { name = "opencv-contrib-python", marker = "extra == 'apriltag'", specifier = "==4.10.0.84" }, + { name = "opencv-python" }, + { name = "pandas", marker = "extra == 'learning'" }, + { name = "pillow", marker = "extra == 'perception'" }, + { name = "pin", specifier = ">=3.3.0" }, + { name = "pin-pink", marker = "extra == 'manipulation'", specifier = ">=4.2.0" }, + { name = "piper-sdk", marker = "extra == 'manipulation'" }, + { name = "playground", marker = "extra == 'sim'", specifier = ">=0.0.5" }, + { name = "plotext", specifier = "==5.3.2" }, + { name = "plum-dispatch", specifier = "==2.5.7" }, + { name = "portal", marker = "extra == 'misc'" }, + { name = "protobuf", specifier = ">=6.33.5,<7" }, + { name = "psutil", specifier = ">=7.0.0" }, + { name = "pyarrow", marker = "extra == 'learning'" }, + { name = "pycollada", marker = "extra == 'manipulation'" }, + { name = "pydantic" }, + { name = "pydantic-settings", specifier = ">=2.11.0,<3" }, + { name = "pygame", marker = "extra == 'sim'", specifier = ">=2.6.1" }, + { name = "pymavlink", marker = "extra == 'drone'" }, + { name = "pyrealsense2-extended", marker = "sys_platform != 'darwin' and extra == 'manipulation'" }, + { name = "python-dotenv" }, + { name = "python-multipart", marker = "extra == 'misc'", specifier = ">=0.0.27" }, + { name = "pytorch-ignite", marker = "extra == 'grasp'" }, + { name = "pyturbojpeg", specifier = "==1.8.2" }, + { name = "pyyaml", marker = "extra == 'manipulation'", specifier = ">=6.0" }, + { name = "qpsolvers", extras = ["proxqp"], marker = "extra == 'manipulation'", specifier = ">=4.12.0" }, + { name = "reactivex" }, + { name = "reportlab", marker = "extra == 'apriltag'", specifier = ">=4.5.0" }, + { name = "rerun-sdk", specifier = "==0.32.0a1" }, + { name = "rerun-sdk", marker = "extra == 'visualization'", specifier = "==0.32.0a1" }, + { name = "roboplan", marker = "extra == 'manipulation'", specifier = ">=0.0.100" }, + { name = "roboplan", marker = "extra == 'manipulation-toppra'", specifier = ">=0.0.100" }, + { name = "scipy", specifier = ">=1.15.1" }, + { name = "sortedcontainers", specifier = "==2.4.0" }, + { name = "sounddevice", marker = "extra == 'agents'" }, + { name = "soundfile", marker = "extra == 'web'" }, + { name = "sqlite-vec", specifier = ">=0.1.6" }, + { name = "sse-starlette", marker = "extra == 'web'", specifier = ">=2.2.1" }, + { name = "structlog", specifier = ">=25.5.0,<26" }, + { name = "tensorboard", marker = "extra == 'misc'", specifier = "==2.20.0" }, + { name = "terminaltexteffects", specifier = "==0.12.2" }, + { name = "textual", specifier = "==3.7.1" }, + { name = "textual-serve", specifier = ">=1.1.1,<2" }, + { name = "timm", marker = "extra == 'misc'", specifier = ">=1.0.15" }, + { name = "toolz", specifier = ">=1.1.0" }, + { name = "torch", marker = "extra == 'grasp'" }, + { name = "torchreid", marker = "extra == 'misc'", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], marker = "extra == 'perception'", specifier = ">=4.53.0,<4.54" }, + { name = "trimesh", marker = "extra == 'manipulation'" }, + { name = "typer", specifier = ">=0.19.2,<1" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'", specifier = ">=4.7" }, + { name = "ultralytics", marker = "extra == 'perception'", specifier = ">=8.3.70" }, + { name = "unitree-sdk2py-dimos", marker = "extra == 'unitree-dds'", specifier = ">=1.0.2" }, + { name = "unitree-webrtc-connect", marker = "extra == 'unitree'", specifier = ">=2.1.2" }, + { name = "uvicorn", marker = "extra == 'web'", specifier = ">=0.34.0" }, + { name = "vgn", marker = "extra == 'grasp'", git = "https://github.com/ethz-asl/vgn.git?rev=d7af0622433f52ae88ebe81533f12b46b33e951a" }, + { name = "viser", extras = ["urdf"], marker = "extra == 'manipulation'", specifier = ">=1.0.29" }, + { name = "websocket-client", specifier = ">=1.8" }, + { name = "xacro", marker = "extra == 'manipulation'" }, + { name = "xarm-python-sdk", marker = "extra == 'manipulation'", specifier = ">=1.17.0" }, + { name = "xarm-python-sdk", marker = "extra == 'misc'", specifier = ">=1.17.0" }, +] +provides-extras = ["misc", "visualization", "learning", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "manipulation-toppra", "grasp", "cpu", "cuda", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] + +[package.metadata.requires-dev] +autofix = [{ name = "ruff", specifier = "==0.14.3" }] +lint = [ + { name = "aiortc", specifier = ">=1.14.0" }, + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "ipython" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "moondream" }, + { name = "mypy", specifier = "==1.19.0" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "openai-whisper" }, + { name = "pandas-stubs", specifier = ">=2.3.2.250926,<3" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "python-can", specifier = ">=4" }, + { name = "python-socketio", specifier = ">=5.16.1" }, + { name = "ruff", specifier = "==0.14.3" }, + { name = "sounddevice", specifier = ">=0.5.5" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "trimesh", specifier = ">=4.12" }, + { name = "types-pyaudio" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915,<7" }, + { name = "types-reportlab", specifier = ">=4.5.0" }, + { name = "types-requests", specifier = ">=2.32.4.20260107,<3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "watchdog", specifier = ">=3.0.0" }, + { name = "xacro" }, +] +project-deps = [ + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "moondream" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "xacro" }, +] +tests = [ + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "coverage", specifier = ">=7.0" }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu", "learning", "manipulation-toppra"] }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "maturin", specifier = ">=1.7" }, + { name = "md-babel-py", specifier = ">=1.2.0" }, + { name = "moondream" }, + { name = "mujoco", specifier = ">=3.3.4" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "py-spy" }, + { name = "pygame", specifier = ">=2.6.1" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "pytest-env", specifier = "==1.1.5" }, + { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, + { name = "pytest-mock", specifier = "==3.15.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, + { name = "pytest-xdist", specifier = ">=3.5.0" }, + { name = "python-can", specifier = ">=4" }, + { name = "python-lsp-ruff", specifier = "==2.3.0" }, + { name = "python-lsp-server", extras = ["all"], specifier = "==1.14.0" }, + { name = "requests-mock", specifier = "==1.12.1" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "unitree-webrtc-connect", specifier = ">=2.1.2" }, + { name = "viser", extras = ["urdf"], marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=1.0.29" }, + { name = "watchdog", specifier = ">=3.0.0" }, + { name = "xacro" }, +] +tests-self-hosted = [ + { name = "chromadb", specifier = ">=1.0.0" }, + { name = "coverage", specifier = ">=7.0" }, + { name = "dimos", extras = ["agents", "perception", "manipulation", "sim", "unitree", "misc"] }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu", "learning", "manipulation-toppra"] }, + { name = "dimos", extras = ["web", "visualization"] }, + { name = "einops", specifier = ">=0.8.1" }, + { name = "gdown", specifier = "==6.0.0" }, + { name = "googlemaps", specifier = ">=4.10.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "langchain", specifier = "==1.2.3" }, + { name = "langchain-core", specifier = "==1.3.3" }, + { name = "langchain-openai", specifier = ">=1,<2" }, + { name = "lap", specifier = ">=0.5.12" }, + { name = "maturin", specifier = ">=1.7" }, + { name = "mcap", specifier = ">=1.2.0" }, + { name = "md-babel-py", specifier = ">=1.2.0" }, + { name = "moondream" }, + { name = "mujoco", specifier = ">=3.3.4" }, + { name = "ollama", specifier = ">=0.6.0" }, + { name = "open-clip-torch", specifier = "==3.2.0" }, + { name = "openai" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "py-spy" }, + { name = "pybind11", specifier = ">=2.12" }, + { name = "pygame", specifier = ">=2.6.1" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "pytest-env", specifier = "==1.1.5" }, + { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, + { name = "pytest-mock", specifier = "==3.15.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, + { name = "pytest-xdist", specifier = ">=3.5.0" }, + { name = "python-can", specifier = ">=4" }, + { name = "python-lsp-ruff", specifier = "==2.3.0" }, + { name = "python-lsp-server", extras = ["all"], specifier = "==1.14.0" }, + { name = "requests-mock", specifier = "==1.12.1" }, + { name = "tensorboard", specifier = "==2.20.0" }, + { name = "torch" }, + { name = "torchreid", specifier = "==0.2.5" }, + { name = "transformers", extras = ["torch"], specifier = "==4.53.3" }, + { name = "ultralytics", specifier = ">=8.3.70" }, + { name = "unitree-webrtc-connect", specifier = ">=2.1.2" }, + { name = "viser", extras = ["urdf"], marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = ">=1.0.29" }, + { name = "watchdog", specifier = ">=3.0.0" }, + { name = "xacro" }, +] + +[[package]] +name = "dimos-lcm" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "foxglove-websocket" }, + { name = "lcm-dimos-fork" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b2/4cdd2bce665ab633313b5d371e0a63fcae2cf77a934a2a9bf820db88a540/dimos_lcm-0.1.3.tar.gz", hash = "sha256:5f7dbd3055f299823bc0e450c59583ad5a2d093c182deec8a86073853881bb09", size = 405688, upload-time = "2026-06-03T07:20:20.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/19/2d8babf544993508359922ea59db2280d42efa0d5a80d4d0cae22ce40d67/dimos_lcm-0.1.3-py3-none-any.whl", hash = "sha256:63317225a0b4ab0f05e4b656f3f78ac9ba4e914998da2c689ca839cbbd83d57d", size = 1714087, upload-time = "2026-06-03T07:20:22.689Z" }, +] + +[[package]] +name = "dimos-robosuite-sidecar" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dimos" }, + { name = "dimos-runtime-protocol" }, +] + +[package.optional-dependencies] +robosuite = [ + { name = "mujoco" }, + { name = "robosuite" }, +] + +[package.metadata] +requires-dist = [ + { name = "dimos", editable = "../../" }, + { name = "dimos-runtime-protocol", editable = "../dimos-runtime-protocol" }, + { name = "mujoco", marker = "extra == 'robosuite'", specifier = "<3.10" }, + { name = "robosuite", marker = "extra == 'robosuite'", specifier = ">=1.5" }, +] +provides-extras = ["robosuite"] + +[[package]] +name = "dimos-runtime-protocol" +version = "0.1.0" +source = { editable = "../dimos-runtime-protocol" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "msgpack", marker = "extra == 'msgpack'", specifier = ">=1.1" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, + { name = "typing-extensions", specifier = ">=4.12" }, +] +provides-extras = ["msgpack", "test"] + +[[package]] +name = "dimos-viewer" +version = "0.32.0a1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/12/f5ccd760ed27c930d08289589f420e3d3e4c707cb44bc4bd85a3e7b8cd18/dimos_viewer-0.32.0a1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c0aa61c7369337af9fea5d49ad648fbee36ed50cfdf2463450a61866fc73783", size = 38326738, upload-time = "2026-05-19T14:54:55.116Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/b1e1e0d7d125dfbcb5465af22a44de56ae892d309f07231d4a462b5d6c1b/dimos_viewer-0.32.0a1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:4763823cc555069b703c980a8fa938e1c8f858f1a8acf00b6e257ac7f5be7e93", size = 42253912, upload-time = "2026-05-19T14:54:58.58Z" }, + { url = "https://files.pythonhosted.org/packages/09/d9/cccad09df71d14b84f95fb384213d0d2eb36efb25db11fafe12e3f27454e/dimos_viewer-0.32.0a1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6db249eff2014f746e7b840a9354dc57e182d8b05bee360fdc934f34c0d4a6da", size = 44794930, upload-time = "2026-05-19T14:55:02.2Z" }, + { url = "https://files.pythonhosted.org/packages/ec/37/394f74ad8981c4666d9014189f3fb154e7f796c408d770d94f52ed4b1ccb/dimos_viewer-0.32.0a1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:86f2dc0f5db732b28ce5d9f13749a12b26f3b3c891e68bae8fcb4c065f08ddf5", size = 38326731, upload-time = "2026-05-19T14:55:05.95Z" }, + { url = "https://files.pythonhosted.org/packages/35/0d/677674fd9a8ac73689642cd7c4bb1b177df9d096f39a74dadd1420ffc221/dimos_viewer-0.32.0a1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f22176cea73d6e898230432de19ca07772a7c1293171ad86dae0b7e54e1fbbf5", size = 42253900, upload-time = "2026-05-19T14:55:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/0c32f2a5e0908dcab3d8b652704d68124cbe2d74ca418561e503fa216cff/dimos_viewer-0.32.0a1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f4a4b6e1aa83b50421a7133b22c27c0924b2f984a242d30273087f66928af5d8", size = 44794937, upload-time = "2026-05-19T14:55:12.888Z" }, + { url = "https://files.pythonhosted.org/packages/3f/51/efcf47f154b5940d09d6e480f3f75f00a927396d9be1f5b2523b1ff9d62e/dimos_viewer-0.32.0a1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4cc88e26567955ce964a250c7250cd3d52e99f330d892420699f0580de8c4f3", size = 38326735, upload-time = "2026-05-19T14:55:16.477Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3e/7564e12f24f4678ba5e311a538eda0a2e46add9afcd58d08984e66bc280c/dimos_viewer-0.32.0a1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:68354192271a201a71491245a7b444b0e4047b74c9d2496a395010356917c0c3", size = 42253910, upload-time = "2026-05-19T14:55:20.232Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4b/d97f13e3585582c79037cd24952ac99de593a30c300ddcc8c83d9cab1128/dimos_viewer-0.32.0a1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ebdd64ce20d73e9d6e0256ef099a3d544c14b9b227aa005ba8c362052889b3c4", size = 44794790, upload-time = "2026-05-19T14:55:24.211Z" }, +] + +[[package]] +name = "eigenpy" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/04/15aadcaf4141c791217724c3edc103cd66c5444987cb897a2e7112bb94ee/eigenpy-3.13.0.tar.gz", hash = "sha256:49d3cb46b85696baf5445ac707b48aade07c21579c188dfc1c8ad2f92e05fab5", size = 229750, upload-time = "2026-05-21T11:01:28.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/37/2bd0e146617320909c3f3a1ea0d37fa0f67ad3b5207e872894c64bb65839/eigenpy-3.13.0-0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c6e844454c5a615b58e9e7cab9cb3297ef23bab6e75228b0c044dde828eb64c2", size = 5440359, upload-time = "2026-05-21T11:00:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e8/aef32d79adf393f6cb65afa29eab023a9fbd4a08788bc51d6c8ad121f39c/eigenpy-3.13.0-0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:56ca6e05cc563a6c42c8ba6b4cdb97b62dd24c25885bb79d310951afe064dfa0", size = 4251076, upload-time = "2026-05-21T11:00:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ed/023b4e3eb66c4e4e3ac2ff5e336e71453e1c292975a92402adc982acbcf7/eigenpy-3.13.0-0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7716543b81705c4be8a3c6d11c0c9e43dbb2ef1863cb3379afc696d10b1f0a3b", size = 6229733, upload-time = "2026-05-21T11:00:49.212Z" }, + { url = "https://files.pythonhosted.org/packages/46/2e/2c379bc21214015df4723dfa22c3dc24310149d8d4395fda95156b122020/eigenpy-3.13.0-0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1c294ea951cd88c256666127b39c5881f9320fcfe144d474c095246297ca2cbd", size = 6063953, upload-time = "2026-05-21T11:00:51.805Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fe/20fccfbb855a2fc94cf1b05eac2634abd77f47126c3e0d3e0353665b2f2b/eigenpy-3.13.0-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbcf85629d25d822db935ab9d5415218cff0717d620f87e1f3e7b86f1357cfd4", size = 5440358, upload-time = "2026-05-21T11:00:53.805Z" }, + { url = "https://files.pythonhosted.org/packages/ee/52/fa7433226e3af2437b70bb2d8357173cae74abe3ead4240c9b136a794043/eigenpy-3.13.0-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e4d51aa0980fd7b4d47acc9c2ac9c4e9bbb6a6d1e2149ba2426292f2972276e", size = 4251073, upload-time = "2026-05-21T11:00:55.921Z" }, + { url = "https://files.pythonhosted.org/packages/66/1e/6c3e45fbd37812faf9343c70a7661f376c123259f9e43e6f2b4fc4211f5c/eigenpy-3.13.0-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:aabe2f552e6ecae5381446bc03ba612469a10a06dfc471bbf2f194e0edcd6974", size = 6228917, upload-time = "2026-05-21T11:00:58.128Z" }, + { url = "https://files.pythonhosted.org/packages/83/a3/79326cff7ab8a55cd9024eee4ddf3082ac9f7be2c978fc5f5b056affc968/eigenpy-3.13.0-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:bca70df5845a3598f1ddd77a3ce46ef5eb28228c85e362e4f75115c6cb80dcff", size = 6064372, upload-time = "2026-05-21T11:01:00.376Z" }, + { url = "https://files.pythonhosted.org/packages/07/03/bfc7a04ef67dd330f7cee8c984d6d101457a19dd9612e5a15306417b62c2/eigenpy-3.13.0-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2203a3073e54a9d55b9766b2d8fdaa6eae3e733742ac5862ca8afbd4e55021ae", size = 5485488, upload-time = "2026-05-21T11:01:02.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/61/cb30323bce5cec4a5a496e41972e12110638dbdba94f2d23814dd82f0eb4/eigenpy-3.13.0-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a844167b3dcb6157cd782fe8cd0396153548eccdcfe81e95803f2499f306ed18", size = 4261367, upload-time = "2026-05-21T11:01:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/fb/90/28a30554ff1a6794a2ef91ffc021737796441d617acd649efecf6b307cce/eigenpy-3.13.0-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5524d36fe0705d0097a42e0d18e4eb3f49dc1e28be301200ab7af7d58b9ae5f", size = 6230829, upload-time = "2026-05-21T11:01:07.631Z" }, + { url = "https://files.pythonhosted.org/packages/5f/39/f3f15daffdcecc31612135ae0f77603ce6ba88c9fcc1c6a5f1e53b2eb0f9/eigenpy-3.13.0-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cde8ed4080da67e0c037ba1453c8d33c74465714ce5196781b640dcc398579a5", size = 6068314, upload-time = "2026-05-21T11:01:10.279Z" }, +] + +[[package]] +name = "etils" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/a0/522bbff0f3cdd37968f90dd7f26c7aa801ed87f5ba335f156de7f2b88a48/etils-1.13.0.tar.gz", hash = "sha256:a5b60c71f95bcd2d43d4e9fb3dc3879120c1f60472bb5ce19f7a860b1d44f607", size = 106368, upload-time = "2025-07-15T10:29:10.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/98/87b5946356095738cb90a6df7b35ff69ac5750f6e783d5fbcc5cb3b6cbd7/etils-1.13.0-py3-none-any.whl", hash = "sha256:d9cd4f40fbe77ad6613b7348a18132cc511237b6c076dbb89105c0b520a4c6bb", size = 170603, upload-time = "2025-07-15T10:29:09.076Z" }, +] + +[package.optional-dependencies] +epath = [ + { name = "fsspec", marker = "python_full_version < '3.11'" }, + { name = "importlib-resources", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "zipp", marker = "python_full_version < '3.11'" }, +] + +[[package]] +name = "etils" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/26/ce/6e067242fde898841922ac6fc82b0bb2fe35c38e995880bdffdfbe30182a/etils-1.14.0.tar.gz", hash = "sha256:8136e7f4c4173cd0af0ca5481c4475152f0b8686192951eefa60ee8711e1ede4", size = 108127, upload-time = "2026-03-04T17:41:36.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/3d/589663aeeacd59bb2f3e8596bfd3e81cf0fb18d70bb433199041f469771b/etils-1.14.0-py3-none-any.whl", hash = "sha256:b5df7341f54dbe1405a4450b2741207b4a8c279780402b45f87202b94dfc52b4", size = 172934, upload-time = "2026-03-04T17:41:35.01Z" }, +] + +[package.optional-dependencies] +epath = [ + { name = "fsspec", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "zipp", marker = "python_full_version >= '3.11'" }, +] + +[[package]] +name = "evdev" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "foxglove-websocket" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/b5/df32ac550eb0df9000ed78d872eb19738edecfd88f47fe08588d5066f317/foxglove_websocket-0.1.4.tar.gz", hash = "sha256:2ec8936982e478d103dd90268a572599fc0cce45a4ab95490d5bc31f7c8a8af8", size = 16616, upload-time = "2025-07-14T20:26:28.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/73/3a3e6cb864ddf98800a9236ad497d32e5b50eb1682ac659f7d669d92faec/foxglove_websocket-0.1.4-py3-none-any.whl", hash = "sha256:772e24e2c98bdfc704df53f7177c8ff5bab0abc4dac59a91463aca16debdd83a", size = 14392, upload-time = "2025-07-14T20:26:26.899Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[[package]] +name = "glfw" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/72/642d4f12f61816ac96777f7360d413e3977a7dd08237d196f02da681b186/glfw-2.10.0.tar.gz", hash = "sha256:801e55d8581b34df9aa2cfea43feb06ff617576e2a8cc5dac23ee75b26d10abe", size = 31475, upload-time = "2025-09-12T08:54:38.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/1f/a9ce08b1173b0ab625ee92f0c47a5278b3e76fd367699880d8ee7d56c338/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-macosx_10_6_intel.whl", hash = "sha256:5f365a8c94bcea71ec91327e7c16e7cf739128479a18b8c1241b004b40acc412", size = 105329, upload-time = "2025-09-12T08:54:27.938Z" }, + { url = "https://files.pythonhosted.org/packages/7c/96/5a2220abcbd027eebcf8bedd28207a2de168899e51be13ba01ebdd4147a1/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-macosx_11_0_arm64.whl", hash = "sha256:5328db1a92d07abd988730517ec02aa8390d3e6ef7ce98c8b57ecba2f43a39ba", size = 102179, upload-time = "2025-09-12T08:54:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/a5bd1d9e1808f400102bd7d328c4ac17b65fb2fc8014014ec6f23d02f662/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux2014_aarch64.whl", hash = "sha256:312c4c1dd5509613ed6bc1e95a8dbb75a36b6dcc4120f50dc3892b40172e9053", size = 230039, upload-time = "2025-09-12T08:54:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/80/aa/3b503c448609dee6cb4e7138b4109338f0e65b97be107ab85562269d378d/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux2014_x86_64.whl", hash = "sha256:59c53387dc08c62e8bed86bbe3a8d53ab1b27161281ffa0e7f27b64284e2627c", size = 241984, upload-time = "2025-09-12T08:54:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2d/bfe39a42cad8e80b02bf5f7cae19ba67832c1810bbd3624a8e83153d74a4/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux_2_28_aarch64.whl", hash = "sha256:c6f292fdaf3f9a99e598ede6582d21c523a6f51f8f5e66213849101a6bcdc699", size = 231052, upload-time = "2025-09-12T08:54:32.859Z" }, + { url = "https://files.pythonhosted.org/packages/f7/02/6e639e90f181dc9127046e00d0528f9f7ad12d428972e3a5378b9aefdb0b/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux_2_28_x86_64.whl", hash = "sha256:7916034efa867927892635733a3b6af8cd95ceb10566fd7f1e0d2763c2ee8b12", size = 243525, upload-time = "2025-09-12T08:54:34.006Z" }, + { url = "https://files.pythonhosted.org/packages/84/06/cb588ca65561defe0fc48d1df4c2ac12569b81231ae4f2b52ab37007d0bd/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-win32.whl", hash = "sha256:6c9549da71b93e367b4d71438798daae1da2592039fd14204a80a1a2348ae127", size = 552685, upload-time = "2025-09-12T08:54:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/86/27/00c9c96af18ac0a5eac2ff61cbe306551a2d770d7173f396d0792ee1a59e/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-win_amd64.whl", hash = "sha256:6292d5d6634d668cd23d337e6089491d3945a9aa4ac6e1667b0003520d7caa51", size = 559466, upload-time = "2025-09-12T08:54:37.661Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/de0b33f6f00687499ca1371f22aa73396341b85bf88f1a284f9da8842493/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-macosx_10_6_intel.whl", hash = "sha256:2aab89d2d9535635ba011fc7303390685169a1aa6731ad580d08d043524b8899", size = 105326, upload-time = "2026-01-28T05:57:56.083Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a6/6ea2f73ad4474896d9e38b3ffbe6ffd5a802c738392269e99e8c6621a461/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", hash = "sha256:23936202a107039b5372f0b88ae1d11080746aa1c78910a45d4a0c4cf408cfaa", size = 102180, upload-time = "2026-01-28T05:57:57.787Z" }, + { url = "https://files.pythonhosted.org/packages/58/19/d81b19e8261b9cb51b81d1402167791fef81088dfe91f0c4e9d136fdc5ca/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux2014_aarch64.whl", hash = "sha256:7be06d0838f61df67bd54cb6266a6193d54083acb3624ff3c3812a6358406fa4", size = 230038, upload-time = "2026-01-28T05:57:59.105Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/b035636cd82198b97b51a93efe9cfc4343d6b15cefbd336a3f2be871d848/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux2014_x86_64.whl", hash = "sha256:91d36b3582a766512eff8e3b5dcc2d3ffcbf10b7cf448551085a08a10f1b8244", size = 241983, upload-time = "2026-01-28T05:58:00.352Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b4/f7b6cc022dd7c68b6c702d19da5d591f978f89c958b9bd3090615db0c739/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux_2_28_aarch64.whl", hash = "sha256:27c9e9a2d5e1dc3c9e3996171d844d9df9a5a101e797cb94cce217b7afcf8fd9", size = 231053, upload-time = "2026-01-28T05:58:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3f/efeb7c6801c46e11bd666a5180f0d615f74f72264212f74f39586c6fda9d/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux_2_28_x86_64.whl", hash = "sha256:ce6724bb7cb3d0543dcba17206dce909f94176e68220b8eafee72e9f92bcf542", size = 243522, upload-time = "2026-01-28T05:58:03.517Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b9/b04c3aa0aad2870cfe799f32f8b59789c98e1816bbce9e83f4823c5b840b/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-win32.whl", hash = "sha256:fca724a21a372731edb290841edd28a9fb1ee490f833392752844ac807c0086a", size = 552682, upload-time = "2026-01-28T05:58:05.649Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e1/6d6816b296a529ac9b897ad228b1e084eb1f92319e96371880eebdc874a6/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:823c0bd7770977d4b10e0ed0aef2f3682276b7c88b8b65cfc540afce5951392f", size = 559464, upload-time = "2026-01-28T05:58:07.261Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a8/d4dab8a58fc2e6981fc7a58c4e56ba9d777fb24931cec6a22152edbb3540/glfw-2.10.0-py2.py3-none-macosx_10_6_intel.whl", hash = "sha256:a0d1f29f206219cc291edfb6cace663a86da2470632551c998e3db82d48ea177", size = 105288, upload-time = "2026-03-10T17:21:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/14/61/68d35e001872a7705112418da236fa2418d4f2e5419f8b2837f9b81bb3da/glfw-2.10.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:d28d6f3ef217e64e35dc6fd0a7acb4cec9bfe7cd14dd9b35a7228a87002de154", size = 102139, upload-time = "2026-03-10T17:21:21.645Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/ca5984081aaae07c9d371cb11dc4e4ff603510678ed9b73e58b6c351fe63/glfw-2.10.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:f968b522bb6a0e04aaf4dcac30a476d7229308bb2bac406a60587debb5a61e29", size = 229998, upload-time = "2026-03-10T17:21:23.549Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c4/82ac75fdcfba2896da7a573c0fc7f8ceb8f77ead6866d500d06c32f1c464/glfw-2.10.0-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:68cf3752bdadb6f4bc0a876247c28c88c7251ac39f8af076ed938fdfd71e72dd", size = 241944, upload-time = "2026-03-10T17:21:26.102Z" }, + { url = "https://files.pythonhosted.org/packages/e3/96/9f691823cca5eb6a08f346bd0ff03b78032db9370b509a1e9c8976fb20a5/glfw-2.10.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:44d98de5dbf8f727e0cb29f9b29d29528ea7570f2e6f42f8430a69df05f12b48", size = 231009, upload-time = "2026-03-10T17:21:28.481Z" }, + { url = "https://files.pythonhosted.org/packages/3f/93/977b9e679e356871d428ae7a1139ec767dd5177bed58a6344b4d2199e00f/glfw-2.10.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cca5158d62189e08792b1ae54f92307a282921a0e7783315b467e21b0a381c88", size = 243480, upload-time = "2026-03-10T17:21:30.538Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/cea9569c8f2188b0a104472951420434a3e1f5cf26f5836ef9d7227a1a30/glfw-2.10.0-py2.py3-none-win32.whl", hash = "sha256:5e024509989740e8e7b86cc4aab508195495f79879072b0e1f68bd036a2916ad", size = 552641, upload-time = "2026-03-10T17:21:32.653Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9b/4366ad3e1c0688146c70aa6143584d6a8d88583b9390f106250e25a3d5cd/glfw-2.10.0-py2.py3-none-win_amd64.whl", hash = "sha256:7f787ee8645781f10e8800438ce4357ab38c573ffb191aba380c1e72eba6311c", size = 559423, upload-time = "2026-03-10T17:21:34.766Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "janus" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/7f/69884b6618be4baf6ebcacc716ee8680a842428a19f403db6d1c0bb990aa/janus-2.0.0.tar.gz", hash = "sha256:0970f38e0e725400496c834a368a67ee551dc3b5ad0a257e132f5b46f2e77770", size = 22910, upload-time = "2024-12-13T12:59:08.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/34/65604740edcb20e1bda6a890348ed7d282e7dd23aa00401cbe36fd0edbd9/janus-2.0.0-py3-none-any.whl", hash = "sha256:7e6449d34eab04cd016befbd7d8c0d8acaaaab67cb59e076a69149f9031745f9", size = 12161, upload-time = "2024-12-13T12:59:06.106Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "lcm-dimos-fork" +version = "1.5.2.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/53/26e5c821858fff27ed9bf5be6e848a63db89bcd6a0c4cb8546dce3396a7d/lcm_dimos_fork-1.5.2.post1.tar.gz", hash = "sha256:3ec3703605ea1ea2f82ab327d73eb7fddf3775c1365e4a112314e11f389ad72f", size = 5538346, upload-time = "2026-06-03T07:09:31.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/59/81490fca92a3df85dc5bf656f98fdfd91b1f0f489abe806057e71c87cb53/lcm_dimos_fork-1.5.2.post1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:78f8abba25e0a48d6c6e616064cf60336de730838819d5fdd97c5464705c905f", size = 3501132, upload-time = "2026-06-03T07:09:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/8ab9a6e2894f02e1deaa49640d8a108a0ed04f9ef273691f7551da40d5e2/lcm_dimos_fork-1.5.2.post1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d96406f6d7dede356e9abb41d99191eed2dbf0ac50a1b8276e254bcf6c747c91", size = 2861833, upload-time = "2026-06-03T07:09:28.212Z" }, + { url = "https://files.pythonhosted.org/packages/39/0f/fc280fd9a2e9eb5ebd40ad8631715d251f6471be3c1c8cae5ef068a4849c/lcm_dimos_fork-1.5.2.post1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d4b59cbc8314e7dd0c92f9efd72e070b4a95cb3f7316aa63745b3b5fad80e71a", size = 2882418, upload-time = "2026-06-03T07:09:21.198Z" }, + { url = "https://files.pythonhosted.org/packages/45/50/8c640088b7722d11d6c1acec133afa44e344cbb5c25236f3ca9b8bdb0d4e/lcm_dimos_fork-1.5.2.post1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e36c02538a5f4f3b7c4bf9cf7f130001deb4087e6c4ab2b87a92b63b5800eaf5", size = 3501233, upload-time = "2026-06-03T07:09:18.794Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e3/c2f4e3d67129caf105f9dade2c0a77755ea1d11e0bf13eb6041e8bf69954/lcm_dimos_fork-1.5.2.post1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ca37f8bda7733084e3a840ca71df139413fa9d7187ef39eb31425301fc8effaa", size = 2861890, upload-time = "2026-06-03T07:09:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9d/f19698bc9888c0e7d1244b02e20cb4f4fa83617f046760e7f2f21799008b/lcm_dimos_fork-1.5.2.post1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3ad82221708a7510edd7ea484733efe40c82bdb553816679b4cc0d8967d1b9be", size = 2882420, upload-time = "2026-06-03T07:09:09.346Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f8/0e300ecb1f5719a42adaadd009c27b6543576172e55f5070c8eec3e6093e/lcm_dimos_fork-1.5.2.post1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:75bd1fc3d6365bb47d1ee95c0b5dd267ca5516b393fcbc393e8c35bfb22c34b2", size = 3501253, upload-time = "2026-06-03T07:09:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bf/1673d451d032cff65214692e2431858800e4baecee32f68a9ccfe19919b8/lcm_dimos_fork-1.5.2.post1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c3c4827d7d9d618270b70c564dacbc142dbce5dc03566510ba698bfebf2830b0", size = 2861845, upload-time = "2026-06-03T07:09:14.2Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/a31d94a20c3ac5d6fd3fa89b2de66dbf6a7f01551fdff29d9a5fbf02cba6/lcm_dimos_fork-1.5.2.post1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:700294567efed85e96f2ae435b7c55d8702e15f3d06154f56216c1be5c85b322", size = 2882574, upload-time = "2026-06-03T07:09:23.407Z" }, +] + +[[package]] +name = "libcoal" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-assimp" }, + { name = "cmeel-boost" }, + { name = "cmeel-octomap" }, + { name = "cmeel-qhull" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/44/d59b10af87165def48bf55fcb3f0591274a39322d48cb92d608d17ed86b5/libcoal-3.0.3.tar.gz", hash = "sha256:fd0f887682c73ef2d429caa09721993acb596b9e529c4f37761fe34faa7675ed", size = 1464501, upload-time = "2026-05-21T08:41:47.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/35/853c33314d1dcb50ff8c1d832cbe1533dd4d0cf6be685b96bbb1d5219d35/libcoal-3.0.3-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c1b3a0c4d10a690c61fb47eb42aee32bad827846bc7413238364ab340b1faf84", size = 1683905, upload-time = "2026-05-21T08:41:39.006Z" }, + { url = "https://files.pythonhosted.org/packages/14/bb/c2288420bd28f563ff6557f17d330fa07d6e5c5198b130da7efee9e53b86/libcoal-3.0.3-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c09ff80ff486f00bdcde1fbde14a143933d20c2c46f8567d6f95817339242fbf", size = 1484193, upload-time = "2026-05-21T08:41:40.917Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/d8ef12e6a3a21d40d146ccea38fc29e547d884ebd9f531105902b5408092/libcoal-3.0.3-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:18e203ed53da8b96b192a74c4c041fe33f98b0249bb7ec27a3b67397ce1ecc4d", size = 2257010, upload-time = "2026-05-21T08:41:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a1/062d7d444eb9260244cdced41384933f8cdf5b383ab9ed2810d048720bd3/libcoal-3.0.3-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:28fa1473d80728994f7275b8c8797858959ab77a643f07f2222feb6de155bb8e", size = 2285044, upload-time = "2026-05-21T08:41:45.03Z" }, +] + +[[package]] +name = "libpinocchio" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, + { name = "cmeel-urdfdom" }, + { name = "libcoal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/c9/6c1d428cc8dc10eda45726ac4898bd9ce659366c934e2c37e8e5ba3ae330/libpinocchio-4.0.0.tar.gz", hash = "sha256:425a4ea81fa046238ddb7d8e0c6109bff69a80dd945f84dc3813243b59e3d548", size = 4418773, upload-time = "2026-05-21T13:30:05.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/f4/53010f1cf23def730d774b9a99d33ffe76da1bdb63ba2e877432d7f652bf/libpinocchio-4.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c497750021075ca95715d478314970a82d7b9d0ce0e9138800378db8cbd4ac3d", size = 3677932, upload-time = "2026-05-21T13:29:58.474Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/150954766542d00e9c3ede6ea918f9ec852e7657637e5dc0c842ff059339/libpinocchio-4.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6d617a36b0a0c60774b321f1662d86aeb7161cf88581ae5c2ca4665d765ea16", size = 3116217, upload-time = "2026-05-21T13:30:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/45/65/150b7d6b3986a63d20f95c0f066fadb586fed21ab027bf4cbe8a70230474/libpinocchio-4.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:43d0ee81449126c6f1b184111cac74d83e8903a57401b8d37bde53ed946cf24e", size = 3639624, upload-time = "2026-05-21T13:30:02.244Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b4/9292b05386d51975ffe69ebcc9dee351ee8018eb0dd1acbcdf48352b2b63/libpinocchio-4.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a5b7b8d968f1fe8a57e01fb05464521c09a8d368d2b4e482bee25db018bbd197", size = 3804644, upload-time = "2026-05-21T13:30:04.052Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/f5/a1bde3aa8c43524b0acaf3f72fb3d80a32dd29dbb42d7dc434f84584cdcc/llvmlite-0.47.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41270b0b1310717f717cf6f2a9c68d3c43bd7905c33f003825aebc361d0d1b17", size = 37232772, upload-time = "2026-03-31T18:28:12.198Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fb/76d88fc05ee1f9c1a6efe39eb493c4a727e5d1690412469017cd23bcb776/llvmlite-0.47.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f9d118bc1dd7623e0e65ca9ac485ec6dd543c3b77bc9928ddc45ebd34e1e30a7", size = 56275179, upload-time = "2026-03-31T18:28:15.725Z" }, + { url = "https://files.pythonhosted.org/packages/4d/08/29da7f36217abd56a0c389ef9a18bea47960826e691ced1a36c92c6ce93c/llvmlite-0.47.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea5cfb04a6ab5b18e46be72b41b015975ba5980c4ddb41f1975b83e19031063", size = 55128632, upload-time = "2026-03-31T18:28:19.946Z" }, + { url = "https://files.pythonhosted.org/packages/df/f8/5e12e9ed447d65f04acf6fcf2d79cded2355640b5131a46cee4c99a5949d/llvmlite-0.47.0-cp310-cp310-win_amd64.whl", hash = "sha256:166b896a2262a2039d5fc52df5ee1659bd1ccd081183df7a2fba1b74702dd5ea", size = 38138402, upload-time = "2026-03-31T18:28:23.327Z" }, + { url = "https://files.pythonhosted.org/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/45/2466d73d79e3940cad4b26761f356f19fd33f4409c96f100e01a5c566909/lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d", size = 207396, upload-time = "2025-11-03T13:01:24.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/7da96077a7e8918a5a57a25f1254edaf76aefb457666fcc1066deeecd609/lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f", size = 207154, upload-time = "2025-11-03T13:01:26.922Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/0fb54f84fd1890d4af5bc0a3c1fa69678451c1a6bd40de26ec0561bb4ec5/lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3", size = 1291053, upload-time = "2025-11-03T13:01:28.396Z" }, + { url = "https://files.pythonhosted.org/packages/15/45/8ce01cc2715a19c9e72b0e423262072c17d581a8da56e0bd4550f3d76a79/lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758", size = 1278586, upload-time = "2025-11-03T13:01:29.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/7be9b09015e18510a09b8d76c304d505a7cbc66b775ec0b8f61442316818/lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1", size = 1367315, upload-time = "2025-11-03T13:01:31.054Z" }, + { url = "https://files.pythonhosted.org/packages/2a/94/52cc3ec0d41e8d68c985ec3b2d33631f281d8b748fb44955bc0384c2627b/lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc", size = 88173, upload-time = "2025-11-03T13:01:32.643Z" }, + { url = "https://files.pythonhosted.org/packages/ca/35/c3c0bdc409f551404355aeeabc8da343577d0e53592368062e371a3620e1/lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd", size = 99492, upload-time = "2025-11-03T13:01:33.813Z" }, + { url = "https://files.pythonhosted.org/packages/1d/02/4d88de2f1e97f9d05fd3d278fe412b08969bc94ff34942f5a3f09318144a/lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397", size = 91280, upload-time = "2025-11-03T13:01:35.081Z" }, + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] +plugins = [ + { name = "mdit-py-plugins" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cycler", marker = "python_full_version < '3.11'" }, + { name = "fonttools", marker = "python_full_version < '3.11'" }, + { name = "kiwisolver", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "pyparsing", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625, upload-time = "2026-04-24T00:11:43.376Z" }, + { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790, upload-time = "2026-04-24T00:11:46.556Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701, upload-time = "2026-04-24T00:11:58.389Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler", marker = "python_full_version >= '3.11'" }, + { name = "fonttools", marker = "python_full_version >= '3.11'" }, + { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pyparsing", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a2/78f662f1b18968531f67d3fcde1b7ea8496920bacd4f16ddb5b79d112e46/matplotlib-3.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f857524b442f0f36e641868ce2171aafa88cb0bc0644f4e1d8a5df9b32649fef", size = 9436261, upload-time = "2026-06-12T02:27:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/5e/92/044f1de43901310202f4c79acf4f141be53b2ca8d8380e2fcefb3d523a75/matplotlib-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57baa92fdc82948ed716eae6d2579d4d6f40965cd8d2f416755b4a72580a3233", size = 9264669, upload-time = "2026-06-12T02:27:37.413Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/f0b4f9ba7ec14a7af8151f3ad71ecfe3561e6ba38cfab1db3681ba4ca112/matplotlib-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630eee0e67d35cce2019a0e670719f4816e3b86aff0fa72729f6c69786fceb45", size = 10021076, upload-time = "2026-06-12T02:27:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/4d679c6dcd594a156542080ac907ddccf7b09ca11655c4b28eca8e9ee5da/matplotlib-3.11.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5106c444d0bf966eee2853548c03772af4ab7199118e086c62fbac8ccb07c055", size = 10828999, upload-time = "2026-06-12T02:27:42.433Z" }, + { url = "https://files.pythonhosted.org/packages/07/74/0a3683802037d8cd013144d77c247219b47f2aabace6fdde74faa12bacf7/matplotlib-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d7aea652b58e686444079be3376ef546bffa1eee9b9bb9c472b9fcf6cf410d3", size = 10913103, upload-time = "2026-06-12T02:27:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/970fcbf381e82ec66fdf5da8ea76e2e9240f61a24011ce9fd1d42c37ac2d/matplotlib-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:70a5b3e9a5dab708c0f039709ae7c68d5b4d254e291ef76492cdba230c8bb5e4", size = 9310945, upload-time = "2026-06-12T02:27:46.867Z" }, + { url = "https://files.pythonhosted.org/packages/14/4e/6e7cfed23611265ded53806852343b5c59339e506e84c474a9b5afc3b249/matplotlib-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d68266213e73823ac3be90615bab0cf31f88851e114cdb1dd25dacf3b01e1a7", size = 8999304, upload-time = "2026-06-12T02:27:48.798Z" }, + { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c2/f5da6cd37ed6871f5c9b3c0507ddb69f14d6c36fac4541e4e0c60cb8cdfc/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62", size = 9434094, upload-time = "2026-06-12T02:29:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/f8/07/56f66906e0f87a0c6d0d0acbd34dbc9432b1931d8f26ef618bd6f92932a9/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd", size = 9262183, upload-time = "2026-06-12T02:29:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" }, +] + +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mink" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mujoco" }, + { name = "qpsolvers", extra = ["daqp"] }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/79/af73f2d6938b99f6284b411dd05d25711ae2a7a00c26fc86070efc768c28/mink-1.2.0.tar.gz", hash = "sha256:4e909fa6af682fa7a009d884c53ed7e0e28f942c15ec091d5029174f97c65208", size = 55343, upload-time = "2026-06-20T19:29:22.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/2a/dd5800ac184554eab128f35611ee68c154550b8b39f044327d200b16a4f8/mink-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6a3c3a8c925b4762ffb201400ffd6f682412cd4e0a21ae4268a755a4055712a4", size = 72267, upload-time = "2026-06-20T19:28:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d4/41030ad3297d05bcf0c944803d6d9ff0fc5485ef07697be55529d05b9ca4/mink-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:663201505c1910082d46d3afed539acb549e66f58ff7418b919a4dba0b899cb1", size = 70873, upload-time = "2026-06-20T19:28:47.16Z" }, + { url = "https://files.pythonhosted.org/packages/78/a9/832a4bf928a84212cf1959ed1981e245f7306cb2207880a5a5eedb1e44df/mink-1.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1cd0dfd2c5064a03273c32051796b31f0b1804f3fcd380807830588ac10af213", size = 73584, upload-time = "2026-06-20T19:28:48.391Z" }, + { url = "https://files.pythonhosted.org/packages/ec/8e/79bf229ba3c041f56d0de588ede39b2502190a9b3c4a82c35a6dac8470c7/mink-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26f296a985f0dff475bf69e665070502f73d1f8b1cd9b45cc4b8282ed01d9a5b", size = 72732, upload-time = "2026-06-20T19:28:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/f3/bf/dc480de5a41e77e6a56c187c11e6ae4424ad142b98922c4ade93979a916f/mink-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1123374ced398a4aa8008179892d579617017f31d674172e02b0688a9549c1a7", size = 72700, upload-time = "2026-06-20T19:28:50.698Z" }, + { url = "https://files.pythonhosted.org/packages/e8/14/015d0ce366ea2fb264cec776a2e21e3a7e0d14e3bd60e10e084b2aa8a799/mink-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bc9bab7e311205c05e016acd3a54c488578b8df94756c65a5de505ad6ce77058", size = 73758, upload-time = "2026-06-20T19:28:51.823Z" }, + { url = "https://files.pythonhosted.org/packages/09/48/25e666e4c0cf0eb9cb9ba500e3fe2dd7b55b328cbf1eee02bc7f9f3e6e15/mink-1.2.0-cp310-cp310-win32.whl", hash = "sha256:73e89b5f4b7ac97bb03ec7af4468f204c50d4e8332b4c10bfae5836980ac0f66", size = 74262, upload-time = "2026-06-20T19:28:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/d9ba5c720750f3dcb3f7a3fdc710b967283653aa08f215c3bd614652ef16/mink-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:9b4fe59d8c3f78493771a68b02814c06b9611b2cfeb2110d688c8e41a5d6ecac", size = 74984, upload-time = "2026-06-20T19:28:54.05Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bd/3ea0d46389865fcdcb39c3aede972a05decca01408eb8f2652fe00f788a5/mink-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:da512e1077f822e8d136a1bd6894249130370da2d489782dbfa0d0cd4bff8bc7", size = 72267, upload-time = "2026-06-20T19:28:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/5882d47d7b47a39bf42c3420e32e2f6130d0678af1ca9accdd8778006625/mink-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:33d3221f53aeea69528fbc7aa692c1f2f142cc42437a6f0359636993e8194b53", size = 70877, upload-time = "2026-06-20T19:28:56.271Z" }, + { url = "https://files.pythonhosted.org/packages/13/c6/9ac18c477356b1b7976ad9fd4419211188fb3db77aac8a972ce9b2ce132e/mink-1.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:559279ed1ef3a08decce24ee460deb9fee1e10d38a8e251493a88877ae112237", size = 73585, upload-time = "2026-06-20T19:28:57.284Z" }, + { url = "https://files.pythonhosted.org/packages/b8/29/d45031dbf0c67dcdac0f7681cc5b0cb1e2b4c35951325e2dc16d1d4cb3ff/mink-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92420c92e12d33105a097baee8ee2656ccf63b921fa216841fb2c6655f585dce", size = 72733, upload-time = "2026-06-20T19:28:58.373Z" }, + { url = "https://files.pythonhosted.org/packages/e7/91/ecd8bcd229755f3733b30c3f36f588cb58998166fc9eb32eb0596521f2dd/mink-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2107d9717577a163842e7053985e1a9f3f8f516fe1656b27fdb6cc29977fd9fd", size = 72707, upload-time = "2026-06-20T19:28:59.51Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/b2b0176f3de086e419d7778a62d20ee9cda32b6a1547db8bed94cf665e0d/mink-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2139c07198d41f0de33cee17eb28950adc65d8995e4d60a92d53d6c032d47ff1", size = 73759, upload-time = "2026-06-20T19:29:00.62Z" }, + { url = "https://files.pythonhosted.org/packages/30/5d/a06beef5ec85fa61f09a19f7c21dcfc7d6ee3029a1e0eb3443c315cfa762/mink-1.2.0-cp311-cp311-win32.whl", hash = "sha256:b131cfc9f93a63093c283a1a1fbe8a1aaadf3bff1f97215305ba2034d10e24bb", size = 74264, upload-time = "2026-06-20T19:29:01.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/39/5446f72690ae0cc03f8116078736d85c5594c979512d69d0cb80e72b165d/mink-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:0a0e466b119366e2555dadd921cf68ca58095ee27fd28373c47886af113c7909", size = 74982, upload-time = "2026-06-20T19:29:02.975Z" }, + { url = "https://files.pythonhosted.org/packages/87/64/cc3fb7922f3a2f1a74f3d8f8eb3c3da727478a19cb7305049264c8cd6670/mink-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d20eaf3e4d9bb11fffa03e57413cad4652d760684cc75ee33a47a4ef2a92353b", size = 72329, upload-time = "2026-06-20T19:29:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/97/94/fc7e135f13ed0e1ab02428875f7f55d6058dc098b9321f1b6a53c66599e6/mink-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59fdeb5b0a1363dbebefc56d4a8fdefc5d53ff15ebde0603b5f01758055d0d46", size = 70896, upload-time = "2026-06-20T19:29:05.363Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/e4b336cabdd48f2d73ef0f5f547b4907b50bb8a9aec6403845c03e58c6bb/mink-1.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62bf5ec5b990727e791d26a578658a72a80d20e21317b5824c72c2d00c29d938", size = 73617, upload-time = "2026-06-20T19:29:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/56/26/a9e2a4ded47beacd8fa453434b20773b30852c0bacddac55391791a42454/mink-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04dabf57b64ec716c85c4f89b9d11033cfe3c6ca6e19405fa181ee5fc4a0de9a", size = 72749, upload-time = "2026-06-20T19:29:07.665Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/226968dd2cec7493de039377f809da2910aa52a3ce2262e4ec92925e4043/mink-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee781209c6869c1c47e40fa70895dbd2591039d91507897d3c5dcccedb0acb3b", size = 72748, upload-time = "2026-06-20T19:29:08.727Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4f/f6a15739d463111fd9933e4959103f307538b2cecb1a397d1c2e7a8b25bb/mink-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f1beadd85d6173f9b503aa12363c79d68af60633b86968b08f9dd40babeb2281", size = 73790, upload-time = "2026-06-20T19:29:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4c/5785df8faffb48fe1e0a5381032104aa580b9e1da18d79787186e885ce32/mink-1.2.0-cp312-cp312-win32.whl", hash = "sha256:59ef4a6fa0a75c51d991c2d0aae266c0eb1be79a30d1b9d682292d2828e9983f", size = 74355, upload-time = "2026-06-20T19:29:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/d5e0883b1c296ac67b5922260512ce0f654de45800c8db939a99b5e42640/mink-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f7283282e942f8f8478aff7d795f74a0d2250de3a5d4c161fa5e326d653b2405", size = 75001, upload-time = "2026-06-20T19:29:12.104Z" }, +] + +[[package]] +name = "mujoco" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "etils", version = "1.13.0", source = { registry = "https://pypi.org/simple" }, extra = ["epath"], marker = "python_full_version < '3.11'" }, + { name = "etils", version = "1.14.0", source = { registry = "https://pypi.org/simple" }, extra = ["epath"], marker = "python_full_version >= '3.11'" }, + { name = "glfw" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyopengl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/f6/325fa0c1a1d63b1bb6852e265fb8d7fd8a88ddcd3580dc153e1dceb197dd/mujoco-3.9.0.tar.gz", hash = "sha256:0a1ca878c4a80893251420037a0583252d1356a0cc8d661268627c674c7835f7", size = 923500, upload-time = "2026-05-27T14:49:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/69/e6fb9f09a800d8440dd8816ca1fe6aece40f082b402d04b511748ebccafd/mujoco-3.9.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:30086de10ebcabeac9ab41f424d348e361cf50efc1ae7c895b5ea13e0122fdb5", size = 7427548, upload-time = "2026-05-27T14:48:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/0b/01/4bc30246ea32aacd4a940aa1144d2b37ab2bbd4892ef6aca3ba85ce675b1/mujoco-3.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a023628a0b5b02d05c55ada02dd4da0a6b97555d11e124f19d865824fc98ed8", size = 7496147, upload-time = "2026-05-27T14:49:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/5e/02/e6b307a599332cfd328fced678e86faf793cf54946c0fe01cc7d327de25b/mujoco-3.9.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d5cf68e9c0c69bae74a28042fe480e51b71977c02becc0805bf7a080668beb", size = 6794087, upload-time = "2026-05-27T14:49:02.373Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/05cccbb6bfee9991673900964770c9f97d6ea8970bc375a3f793dc00d63a/mujoco-3.9.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c148824d73487fe5ee29c371eff981645f372ccada1f20ea331288323e37c65e", size = 7296599, upload-time = "2026-05-27T14:49:04.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0e/c48fa9b9372bc1e5d58df39627507b187b257b1ca91d00b5386d667315bd/mujoco-3.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:da56b203c453d87e3c0afa5f9f9ee4d825cbe8516a5ea7e0ba62a1ce71a60349", size = 5878490, upload-time = "2026-05-27T14:49:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/224e87069218bf2a82e56ad46857e1e263169f4f8ca8259e656056c78da6/mujoco-3.9.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:996f6159c43a2b1cf7d732c14b83de5c84dd77237be30d78c47943c836419b72", size = 7440801, upload-time = "2026-05-27T14:49:09.197Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d0/3e68cb1c593b9d2e3a26ee1b40edbf7ca0dc4902b26a008742db43897cba/mujoco-3.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b1cb2d56619570aec1eb27be32e229d592a0ef4be0a3785185dd6bf8072f3044", size = 7507147, upload-time = "2026-05-27T14:49:11.381Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cd/ec99ad2065b644b51c776470083807c58c6c247eb4b9251bf509bc53e465/mujoco-3.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6cd6d02dfecab173de1a58cf207c8f69dd47564b0cc67ba22a6d1d346c73fee", size = 6808142, upload-time = "2026-05-27T14:49:13.163Z" }, + { url = "https://files.pythonhosted.org/packages/cf/88/1463cb47348c40eec1960c0b598b9e9d8f7365e93e93ac59c1115791ca4c/mujoco-3.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b6602cae4d9638652ab5d321db622aa59ec07cfe819d6ea3a5f55283c9147a8", size = 7310328, upload-time = "2026-05-27T14:49:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e5/ff3f1db0cbfa3b757d3250b1a4a46a8ba319012a529d67cf86365fe6572e/mujoco-3.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:f3a381aac55994e012c026f9e43f242eed916cedcfefe1417ba7e37bb1f2f34b", size = 5902883, upload-time = "2026-05-27T14:49:16.88Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9b/86bc30e294f835a04ed261a3271d6f192595fd70e5f9f0bbe7f31a357ddd/mujoco-3.9.0-cp312-cp312-macosx_10_16_x86_64.whl", hash = "sha256:f1d8736589ad736f8ec1601a701a8ab84aff5a2b29244fe58194d8f0a0984865", size = 7467226, upload-time = "2026-05-27T14:49:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/e6/14/5c8d8d660e23bcd6963f3f7df525504cd082000efc2bbc76812b9b7a4af5/mujoco-3.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f38a5d011425585d10a1b4dd9ff941072ffccda2db071b462c86967199a3bb0", size = 7461002, upload-time = "2026-05-27T14:49:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/eb1840f486d322cd5285a2dc2e422cbc22f4e273b66edc0cf9d96cbad47c/mujoco-3.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af0cb8c96f5b02e701f9ab7a9c905d1c655626b3226a4c4da7b28bf2771a4e7", size = 6847433, upload-time = "2026-05-27T14:49:22.974Z" }, + { url = "https://files.pythonhosted.org/packages/d5/01/b2a88b6b73df933d5ab38583240c296684b626a8de3c3bb9a7c2fd356f08/mujoco-3.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8c6769ac1463752ff392d8c4d20014e54220112d772e6ccecdf6bcdb6d43e07", size = 7392160, upload-time = "2026-05-27T14:49:25.543Z" }, + { url = "https://files.pythonhosted.org/packages/66/22/7ce98722ecfb38aa2e3ca9da7bbe2b662ef95ec13acad1c8a63f22e14f39/mujoco-3.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:812406661161ebff1335e5293a6fb2ae4b193a9ea03e3f6579875aef345a3c8f", size = 5974891, upload-time = "2026-05-27T14:49:27.147Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1b/3c5a7daf683a95465bf23504bcd1a2d5db8cd5e5e276ca87505d020dffe9/numba-0.65.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:9d993ed0a257aa4116e6f553f114004bcfdee540c7276ab8ea48f650d514c452", size = 2680870, upload-time = "2026-04-24T02:02:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a4/1831836814018a898e7d252aebe09c0f3ce1f26d145b68264b4ae0be6822/numba-0.65.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f098109f361681e57295f7e84d8ab2426902539a141811de0703ace52826981", size = 3739780, upload-time = "2026-04-24T02:02:13.097Z" }, + { url = "https://files.pythonhosted.org/packages/9c/1b/a813ddc81def09e257d2b1f67521982ce4b06204a87268796ffc8187271c/numba-0.65.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973fd8173f2312815e6b7aaae887c4ce8a817eeff46a4f8840b828305b75bc95", size = 3446722, upload-time = "2026-04-24T02:02:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/ee1d8b3becda384fe0552221641e05aa668a35e8a77470db4db7f6475000/numba-0.65.1-cp310-cp310-win_amd64.whl", hash = "sha256:c63aa0c4193694026452da55d0ef9d85156c1a7a333454c103bb30dec81b7bf8", size = 2747539, upload-time = "2026-04-24T02:02:16.79Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/650500c2eab4534d98e9166f4298e0f3c69c742afdf24e6eabccd1f16ad8/numba-0.65.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7020d74b19cdb8cff16506542fdd510756e28c5e7f3bd0b7f574f0f42272fcd9", size = 2680563, upload-time = "2026-04-24T02:02:18.414Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/0615dbedb98f5b32a35a53290fbdc6e22306968109278d7e58df82d7a9f6/numba-0.65.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f80ed83774b5173abd6581cd8d2165d1d38e13d2e5c8155c0c0b421784745420", size = 3745018, upload-time = "2026-04-24T02:02:20.252Z" }, + { url = "https://files.pythonhosted.org/packages/49/aa/4361698f35bf63bff67dfe6c90493731177f48ede954f77b0588731537bc/numba-0.65.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ed425a43b0a5f9772f2f4e2dd0bbd12eabecae1af0b24efcfd4e053f012aac6", size = 3450962, upload-time = "2026-04-24T02:02:22.449Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9a/af61ec03b3116c161fd7a06b9e8a265729a8718458333e8ffbb06d9a3978/numba-0.65.1-cp311-cp311-win_amd64.whl", hash = "sha256:df40a5028a975b9ea66f6a2a3f7abbdbd541a863070e34ed367aff21141248e4", size = 2747417, upload-time = "2026-04-24T02:02:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "open3d" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "addict" }, + { name = "configargparse" }, + { name = "dash" }, + { name = "flask" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "nbformat" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "pyquaternion" }, + { name = "pyyaml" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tqdm" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/4b/91e8a4100adf0ccd2f7ad21dd24c2e3d8f12925396528d0462cfb1735e5a/open3d-0.19.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:f7128ded206e07987cc29d0917195fb64033dea31e0d60dead3629b33d3c175f", size = 103086005, upload-time = "2025-01-08T07:25:56.755Z" }, + { url = "https://files.pythonhosted.org/packages/c7/45/13bc9414ee9db611cba90b9efa69f66f246560e8ade575f1ee5b7f7b5d31/open3d-0.19.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:5b60234fa6a56a20caf1560cad4e914133c8c198d74d7b839631c90e8592762e", size = 447678387, upload-time = "2025-01-08T07:21:55.27Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/0219416429f88ebc94fcb269fb186b153affe5b91dffe8f9062330d7776d/open3d-0.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:18bb8b86e5fa9e582ed11b9651ff6e4a782e6778c9b8bfc344fc866dc8b5f49c", size = 69150378, upload-time = "2025-01-08T07:27:10.462Z" }, + { url = "https://files.pythonhosted.org/packages/a7/37/8d1746fcb58c37a9bd868fdca9a36c25b3c277bd764b7146419d11d2a58d/open3d-0.19.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:117702467bfb1602e9ae0ee5e2c7bcf573ebcd227b36a26f9f08425b52c89929", size = 103098641, upload-time = "2025-01-08T07:26:12.371Z" }, + { url = "https://files.pythonhosted.org/packages/bc/50/339bae21d0078cc3d3735e8eaf493a353a17dcc95d76bcefaa8edcf723d3/open3d-0.19.0-cp311-cp311-manylinux_2_31_x86_64.whl", hash = "sha256:678017392f6cc64a19d83afeb5329ffe8196893de2432f4c258eaaa819421bb5", size = 447683616, upload-time = "2025-01-08T07:22:48.098Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3c/358f1cc5b034dc6a785408b7aa7643e503229d890bcbc830cda9fce778b1/open3d-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:02091c309708f09da1167d2ea475e05d19f5e81dff025145f3afd9373cbba61f", size = 69151111, upload-time = "2025-01-08T07:27:22.662Z" }, + { url = "https://files.pythonhosted.org/packages/37/c5/286c605e087e72ad83eab130451ce13b768caa4374d926dc735edc20da5a/open3d-0.19.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:9e4a8d29443ba4c83010d199d56c96bf553dd970d3351692ab271759cbe2d7ac", size = 103202754, upload-time = "2025-01-08T07:26:27.169Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/3723e5ade77c234a1650db11cbe59fe25c4f5af6c224f8ea22ff088bb36a/open3d-0.19.0-cp312-cp312-manylinux_2_31_x86_64.whl", hash = "sha256:01e4590dc2209040292ebe509542fbf2bf869ea60bcd9be7a3fe77b65bad3192", size = 447665185, upload-time = "2025-01-08T07:23:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/35a6e0a35aa72420e75dc28d54b24beaff79bcad150423e47c67d2ad8773/open3d-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:665839837e1d3a62524804c31031462c3b548a2b6ed55214e6deb91522844f97", size = 69169961, upload-time = "2025-01-08T07:27:35.392Z" }, +] + +[[package]] +name = "open3d-unofficial-arm" +version = "0.19.0.post9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "dash" }, + { name = "flask" }, + { name = "nbformat" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/f9/edcfaa213800ea278804402baa65693840bc7a323b3de8a31c54ce4e42c8/open3d_unofficial_arm-0.19.0.post9.tar.gz", hash = "sha256:ee300bd557f04750db6e47ccb6c6867c6dd6cfc04169dddeb92505da9ea739ef", size = 5327, upload-time = "2026-04-16T21:21:11.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/e1/847917ccac62d6d1f680f69feb333c513cb1bf7d7ff14c9d1618a5487bec/open3d_unofficial_arm-0.19.0.post9-cp310-cp310-manylinux_2_31_aarch64.whl", hash = "sha256:f8e6923dcfe3c0a0e3ddaf30babc463aa1e71464750be100a2b382eb61d4fc6a", size = 47332813, upload-time = "2026-04-16T21:23:29.257Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4f/c886749c286cdc1163da559c8a7447c11e6ee32cf1b9bbf3a36a3a865739/open3d_unofficial_arm-0.19.0.post9-cp310-cp310-manylinux_2_35_aarch64.whl", hash = "sha256:97a5eaf992816610b2c7d474ecc3ebe3c5044441611883ccfb1201d69030e0a4", size = 48230532, upload-time = "2026-04-16T21:23:43.001Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/657916febb68b6d65539ea57bc50c9e82622a1bbb5af527950d7b3f49644/open3d_unofficial_arm-0.19.0.post9-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:3b407104a000f0e44b27730e84ffef85e25ce6fb9bb09e5368c462449eb4e6f0", size = 48233468, upload-time = "2026-04-16T21:23:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9c/863a42c39d2f3d8dbed617e624b3bac04225a56be193b999009954ec0cac/open3d_unofficial_arm-0.19.0.post9-cp312-cp312-manylinux_2_31_aarch64.whl", hash = "sha256:e104c90aa35ee7c4e2470abb225522f39cff5ab2f3cf9297680be9367e846e56", size = 47305232, upload-time = "2026-04-16T21:24:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/4f2ee6cadddf2bd87dcb6f0f43fdf5acca6b24c550c695c20e328567e61a/open3d_unofficial_arm-0.19.0.post9-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:4f7c325cad5c589363723967b1275a2b6fc3776ebaee4476a44f4baef5754c24", size = 48221802, upload-time = "2026-04-16T21:24:24.015Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[package]] +name = "pin" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, + { name = "cmeel-urdfdom" }, + { name = "coal" }, + { name = "libpinocchio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/40/72fa61797578c9121824645398497d2561ec29eb1d9cb88ca43973435834/pin-4.0.0.tar.gz", hash = "sha256:5bdf7dbf76437f797e8ea4f9a0e286f33e2af3fde38d67efc34832da2edced66", size = 4417957, upload-time = "2026-05-21T17:27:18.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/f4/9ee84da0310e4c39ecb3bbb1512d47f065cdbcbfd78f5fc598ef54f69392/pin-4.0.0-0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:354867681a0fcf431b9c1eac4a69fda7db93ee584cb5ef3c719dbb28831b6b49", size = 7397250, upload-time = "2026-05-21T17:26:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/92/eb/ab48a506084c04af7b50ba432bb436e79b21cd142821726002375ba76b75/pin-4.0.0-0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e97bc2b58b9f334cdf082913f762c88b50582ca65156086e26f693f31d57f59b", size = 7134471, upload-time = "2026-05-21T17:26:38.895Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/fee9f824f7b553e892105098d1ad52a01b7e6d44968f99b022b3147d32ca/pin-4.0.0-0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:70d8d911aeb7cf2f08605358707728d93f17e766e828ae549e2814bc677bdbe1", size = 9981628, upload-time = "2026-05-21T17:26:40.596Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/aa9230695ba54caa37107a9087e6a3432708eef1387214e3fe1c257dc8b2/pin-4.0.0-0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3f9b98a43610478f6e2f555b60cf3040bf9ece25986416347a503e38b76ce04d", size = 10208985, upload-time = "2026-05-21T17:26:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/05/59/551829f6dd81dcb84ea681f107085afae99a45db954083aac1cbaff8a8fa/pin-4.0.0-0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c123b3627af8fbfff255954bb115517cc5a8f3c572765ec259cf67b8eb6d2c7", size = 7397256, upload-time = "2026-05-21T17:26:44.54Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/d91b71ddaa46e0a3990514aa9488631acf8527a26a76f6328cd662dabdeb/pin-4.0.0-0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:322b6c15b8992e01b7ea5f5de91e39dec54022b2c862dd9a0766ac446d7dfc47", size = 7134478, upload-time = "2026-05-21T17:26:46.59Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0e/ed1a5716caf890c4b02481552d60f12df4a587716af453d7d4f8ad9b7198/pin-4.0.0-0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4a81cc911ca16911fffcedcacc9c159f1961691cb1fa65cadef6559515aea136", size = 9979416, upload-time = "2026-05-21T17:26:48.822Z" }, + { url = "https://files.pythonhosted.org/packages/0d/75/cc1bc40bace8ee7382f417f4dacceabcdcd20b716fa66a5de029f98a4715/pin-4.0.0-0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:62ce8dfcc92f43c0f765d9e145a21da914f9b2adb9ee724e87a870f03995fe2d", size = 10207826, upload-time = "2026-05-21T17:26:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/91/ab/f2f7e8b5eba232adba310dafbf78c1a8722fde77a5c5defab88693bfe90e/pin-4.0.0-0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8be5f63b34b7f5da40e6f1f4554ff2f0606c425f89347b0f25f5ce0d3d42a56b", size = 7469991, upload-time = "2026-05-21T17:26:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/88/ca/8cd3f29177213abf9c9714039e1e916694edc935b6644fc33bb037b358d3/pin-4.0.0-0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1abe8107a14d7507983324e3866ed043653d846029148299d1965d0ce32d31e7", size = 7185247, upload-time = "2026-05-21T17:26:55.148Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/76c375d6d0e044094686c566f2e14d39a86f35e1286e546be6d05a82cf6e/pin-4.0.0-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1d9b4df62e71e1be729ceadc344aeda65dac3581caa4368f2e4377a991b66329", size = 9880446, upload-time = "2026-05-21T17:26:57.183Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/5d3400107ea829ff9002786ea483d0ac6590caaa8ba427c2d5f588a1a3cd/pin-4.0.0-0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8ec1a71de6d8ed6de9e567c5c3dff22a044a3c3671590ee887d930763b267ee3", size = 10117715, upload-time = "2026-05-21T17:26:59.353Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + +[[package]] +name = "plotly" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/fd/d72c292d78aadb93d1a9bcd76bf3c678271040c7cf10abe5788b33040a39/plotly-6.8.0.tar.gz", hash = "sha256:e088e7ddc68d4f70e3d66659224727a45296d71d2b8284181862d3d8f1f0d88f", size = 6915161, upload-time = "2026-06-03T18:33:40.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl", hash = "sha256:13c5c4a0f70b74cab1913eda0de49b826df5931708eb6f9c3010040614700ec8", size = 9902055, upload-time = "2026-06-03T18:33:34.26Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "plum-dispatch" +version = "2.5.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/46/ab3928e864b0a88a8ae6987b3da3b7ae32fe0a610264f33272139275dab5/plum_dispatch-2.5.7.tar.gz", hash = "sha256:a7908ad5563b93f387e3817eb0412ad40cfbad04bc61d869cf7a76cd58a3895d", size = 35452, upload-time = "2025-01-17T20:07:31.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/31/21609a9be48e877bc33b089a7f495c853215def5aeb9564a31c210d9d769/plum_dispatch-2.5.7-py3-none-any.whl", hash = "sha256:06471782eea0b3798c1e79dca2af2165bafcfa5eb595540b514ddd81053b1ede", size = 42612, upload-time = "2025-01-17T20:07:26.461Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/bf/a34fee1d624152124fa8355c42f34195ad5fe5233ce5bb87946432047d52/pyarrow-24.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb", size = 35076681, upload-time = "2026-04-21T08:51:46.845Z" }, + { url = "https://files.pythonhosted.org/packages/1d/41/64180033d7027afce12dc96d0fe1f504c6fa112190582b458acea2399530/pyarrow-24.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147", size = 36684260, upload-time = "2026-04-21T08:51:53.642Z" }, + { url = "https://files.pythonhosted.org/packages/57/02/9b9320e673dd8a99411fac78690f3df92f6dd6f59754c750110bca66d64e/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c", size = 45698566, upload-time = "2026-04-21T10:46:02.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/f75e91b9a64c3f33c787e263c93b871ad91b8a4a68c1d5cebddd9840e835/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041", size = 48835562, upload-time = "2026-04-21T10:46:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/a5/63/097510448e47e4091faa41c43ba92f97cecaab8f4535b56a3d149578f634/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491", size = 49394997, upload-time = "2026-04-21T10:46:18.08Z" }, + { url = "https://files.pythonhosted.org/packages/60/6b/c047d6222ab279024a062742d1807e2fbaf27bba88a98637299ff47b9236/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1", size = 51911424, upload-time = "2026-04-21T10:46:25.347Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ba/464cc70761c2a525d97ebd84e21c31ebd47f3ef4bdcee117009f51c46f24/pyarrow-24.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591", size = 27251730, upload-time = "2026-04-21T10:46:30.913Z" }, + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynput" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/c6/e2d415610cfbc78308bee44218a46124aaa3301b1df08814df819b2254a1/pynput-1.8.2.tar.gz", hash = "sha256:f493c87157cd3861b4468f7f896857051762f44ed26f1b641e7cc5840a457087", size = 82818, upload-time = "2026-05-12T19:11:39.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/98/bbeb760852adb27f166ce1617f0e51aabb15f21b1e60ea703f2aed3c78ac/pynput-1.8.2-py2.py3-none-any.whl", hash = "sha256:8cc38cf13a6ab2749cb375678be8a0fd705d7ce49c8001ff5db4007a723bbef1", size = 92028, upload-time = "2026-05-12T19:11:37.89Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/62/80fe6b6bea9e9e7abe2e7a91bfd22115219b2263e435d2da09cf480b6f49/pyobjc_core-12.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aa5c889961d79d7704f17eeb27f80ee791335819c1bb14babeff7b9ea665b5f0", size = 6486390, upload-time = "2026-06-19T13:29:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/4d/0ebdd8144aba94b8fe9828ccee5616a4bf53d1f8bc51cff55f3cce86d695/pyobjc_framework_applicationservices-12.2.1.tar.gz", hash = "sha256:048ea663c9ac75c44a15dc7d5b8d78cbb4c97bf1c76e83835e8d5498e184001f", size = 109342, upload-time = "2026-06-19T16:19:46.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/e7/f51cd6aa79390bf64f2fb09d57f33737bf050eb798137df9392fbba311f9/pyobjc_framework_applicationservices-12.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9fb187b7176e61983e1c4760bba951fd9b3fc539dca4f05998d33628e7539121", size = 32714, upload-time = "2026-06-19T16:05:37.289Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8a/5a9310929bb303c31c04a1c6dc7b3213c9bd964a692d024a00c0643af2c8/pyobjc_framework_applicationservices-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dabec481217b0d0c1ea835e9fef6b0681381b14b3f16a30d4a9d801ee3852dd2", size = 32715, upload-time = "2026-06-19T16:05:38.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/89/39a7462006afbc06c69029fe4181b7359a9da25ae7864ef75f9d3ffb9272/pyobjc_framework_applicationservices-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b", size = 32764, upload-time = "2026-06-19T16:05:39.26Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/c5/f6a5458cc5a598baa7a79ffe86248560c631f5a86f4a3c678fb6a815e78b/pyobjc_framework_cocoa-12.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:05443c1494532779cccff83e9792fdd6f0a4800cac56620132bf28e0bf966e9d", size = 387303, upload-time = "2026-06-19T16:07:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/91/c76f3c5e8e80c7047e43c4c05b3e6fda9a7cefad5aae85487007674c966c/pyobjc_framework_corebluetooth-12.2.1.tar.gz", hash = "sha256:7dbb285295097205bebbcb11f55161e5faa02111108fb7b17536176e31971eb0", size = 37568, upload-time = "2026-06-19T16:20:12.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a8/1a7acdfeb474daa25704f38f20f26b5fa2111b768c6d1c1451417bf8e4a5/pyobjc_framework_corebluetooth-12.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:41dd9c9b4390e7316d189ba32d624ce933f7b25d351268b6e8cbea44f65e55f5", size = 13195, upload-time = "2026-06-19T16:08:25.759Z" }, + { url = "https://files.pythonhosted.org/packages/00/ad/de57d9060e4c5e9ca1a9bdd5b6bd1bdb73b198c0c53953cac445d9b3a84b/pyobjc_framework_corebluetooth-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f11df7052fa2d0524a9dadbc9c578fd3b8f3a350431edfa4ffa9e0a74b6faa32", size = 13195, upload-time = "2026-06-19T16:08:26.961Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c4/7938016860850e28c001dc9b7c653352c43f7aebfffc6d3c5fd087281f22/pyobjc_framework_corebluetooth-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2f8d2ed65c0e98ba044b6a7fc2f9a290a5c579a28d33a57c642f8617ccbcca0f", size = 13217, upload-time = "2026-06-19T16:08:27.802Z" }, +] + +[[package]] +name = "pyobjc-framework-coretext" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9c/4c7f452059dc1d3845b8e627b9113c247a997b9b07518e848c2ab7ff3149/pyobjc_framework_coretext-12.2.1.tar.gz", hash = "sha256:af740e784d7c592c34025ec7165f4f6c1a69b5a2d9075f06e41e4f77c212aed2", size = 97349, upload-time = "2026-06-19T16:20:22.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/5c/8ea3ea3715147c4ccdb7fbfd36635069fa1dd637812052f9453a18d7cec5/pyobjc_framework_coretext-12.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ae7eb1943fb8f1acf9fefdd76fe74751d05beaf4dff1c989cf9237b117a66d6e", size = 30021, upload-time = "2026-06-19T16:09:48.947Z" }, + { url = "https://files.pythonhosted.org/packages/13/53/c262cf5052c648c48b3f7562fcce188fb78ff94e44cf1c48fdfc62fdfcce/pyobjc_framework_coretext-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:02a448675d28005fdb88fb3c585572b02871e9e5d4d08f88334ec937ea5de6e7", size = 30022, upload-time = "2026-06-19T16:09:50.37Z" }, + { url = "https://files.pythonhosted.org/packages/c5/11/c1298c2ec3b0cd19a457a1fd0da47898f894a13df5516f80dc04d1a7a4d9/pyobjc_framework_coretext-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6", size = 30123, upload-time = "2026-06-19T16:09:51.183Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/3f/561653aff3f19873457c95c053f0298da517be89fdfc0ec35115ed5b7030/pyobjc_framework_libdispatch-12.2.1.tar.gz", hash = "sha256:0d24eda41c6c258135077f60d410e704bc7b5a67adcb2ca463918896c7363795", size = 40336, upload-time = "2026-06-19T16:20:56.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/33/362a92511af642d6168bf46369dbc3a98aa20fc2329768748f1e4d02786d/pyobjc_framework_libdispatch-12.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a47e8c53511eaf739f3972c48e0813bf8fd10dc88dc5543afceaec6554f5c4c2", size = 20486, upload-time = "2026-06-19T16:12:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b0/dc263ed1cee54badccaf60f061de1e25bea95504984401a22bee274b5f59/pyobjc_framework_libdispatch-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e295775c76eace23f60e53ef74b0f79429c665f91a870e4665c4b9887466efa", size = 20488, upload-time = "2026-06-19T16:12:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8f/42cfa987c07a2b5ce8c236a42b0fb388b8807dac72c25e004cd4905ea9a3/pyobjc_framework_libdispatch-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8f41b5021ff70bc51220a79b41ebd1eacb55fe3ceb67448594f30e491a2c42a5", size = 15656, upload-time = "2026-06-19T16:12:50.361Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/8f/7b2abbade50ed918e59c3dfe14aece0f0f8a4dc43a3884fd4f2508c7787a/pyobjc_framework_quartz-12.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c8fed57ac8a1927e4fe4f48ab4042cbc1087d881743182b6f3f2e6e227a9209f", size = 218013, upload-time = "2026-06-19T16:16:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, +] + +[[package]] +name = "pyopengl" +version = "3.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyquaternion" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/3d092aa20efaedacb89c3221a92c6491be5b28f618a2c36b52b53e7446c2/pyquaternion-0.9.9.tar.gz", hash = "sha256:b1f61af219cb2fe966b5fb79a192124f2e63a3f7a777ac3cadf2957b1a81bea8", size = 15530, upload-time = "2020-10-05T01:31:30.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b3/d8482e8cacc8ea15a356efea13d22ce1c5914a9ee36622ba250523240bf2/pyquaternion-0.9.9-py3-none-any.whl", hash = "sha256:e65f6e3f7b1fdf1a9e23f82434334a1ae84f14223eee835190cd2e841f8172ec", size = 14361, upload-time = "2020-10-05T01:31:37.575Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pyturbojpeg" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/e8/0cbd6e4f086a3b9261b2539ab5ddb1e3ba0c94d45b47832594d4b4607586/PyTurboJPEG-1.8.2.tar.gz", hash = "sha256:b7d9625bbb2121b923228fc70d0c2b010b386687501f5b50acec4501222e152b", size = 12694, upload-time = "2025-06-22T07:26:45.861Z" } + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "qpsolvers" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/35/fc1f8b033b7ba6ecede5db093741df6a3adba6059346a586550f46254315/qpsolvers-4.12.0.tar.gz", hash = "sha256:2ec9c46294ae02bdac8969352726d2c2d5ed85f00c096881b27909e4f103ca7c", size = 235173, upload-time = "2026-05-08T09:18:06.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/68/c6e6f378f79dff83584ed18acae88c1de1ea56afc5ba9a2831ad3716083d/qpsolvers-4.12.0-py3-none-any.whl", hash = "sha256:30545f3eba98655a3c9410a071c70bb016e320a8d80a0c1a854aba00963b9b7f", size = 106752, upload-time = "2026-05-08T09:18:02.366Z" }, +] + +[package.optional-dependencies] +daqp = [ + { name = "daqp" }, +] + +[[package]] +name = "reactivex" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/af/38a4b62468e4c5bd50acf511d86fe62e65a466aa6abb55b1d59a4a9e57f3/reactivex-4.1.0.tar.gz", hash = "sha256:c7499e3c802bccaa20839b3e17355a7d939573fded3f38ba3d4796278a169a3d", size = 113482, upload-time = "2025-11-05T21:44:24.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/9e/3c2f5d3abb6c5d82f7696e1e3c69b7279049e928596ce82ed25ca97a08f3/reactivex-4.1.0-py3-none-any.whl", hash = "sha256:485750ec8d9b34bcc8ff4318971d234dc4f595058a1b4435a74aefef4b2bc9bd", size = 218588, upload-time = "2025-11-05T21:44:23.015Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rerun-sdk" +version = "0.32.0a1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "pyarrow" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e0/5d92c02d6ca63b5692e72ad3c5b89e32d44c3a6a99a74be3cb9a9d2c9741/rerun_sdk-0.32.0a1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:704e031c4ed3eb83837ce37b73741082ab669a2b4c88348a08ba61e3ba6d8656", size = 123843351, upload-time = "2026-04-29T18:59:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/1daa4751a281bdb6cda3473229b2746779093ffaba70dd868a3074ef3392/rerun_sdk-0.32.0a1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0a821b30f287dabbf694aa1715e4dad0e31990cf4b612757c895f096885512e9", size = 133447478, upload-time = "2026-04-29T18:59:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/84170e6722a09294b57d578fc1d01266c6e78408a544876f5271ecfa66a9/rerun_sdk-0.32.0a1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8045beb820372e092a6cf100fb87091fa804168e5cf8a0c29f97cdee56b134d8", size = 137724590, upload-time = "2026-04-29T18:59:55.238Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2f/09845dcdf14047cc8118566572628c533f57f6a232ee0b3fd61283b153e8/rerun_sdk-0.32.0a1-cp310-abi3-win_amd64.whl", hash = "sha256:1d6f8c3d50699ae075551a62e971730870a685cb74189b550a348561f37f9fe5", size = 118766780, upload-time = "2026-04-29T19:00:00.121Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "robosuite" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mink" }, + { name = "mujoco" }, + { name = "numba" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "pynput" }, + { name = "pytest" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "termcolor" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/78/07c2eb013cf94737745f4084c71d05c921e449a3bc4e72a662010331cf34/robosuite-1.5.1.tar.gz", hash = "sha256:58ee509878ca084f7658aae3a58ef363b44a8fbdcc92cd7bd349977cdc476029", size = 150852523, upload-time = "2025-02-08T00:47:34.9Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/15/82093cadf23811463d0b52ec6745949356b66badf6e25bee64ec82aa8689/robosuite-1.5.1-py3-none-any.whl", hash = "sha256:39810a9e9f193455fcb13a9b4846424abef77481ac3091892c2077c88dcdc153", size = 152011410, upload-time = "2025-02-08T00:47:14.258Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, + { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, + { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, + { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, + { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "narwhals", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "terminaltexteffects" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/92/0eb3f0ad206bf449b7db75f061202dce27d8cb90e598ce3c7d32c0bd80b9/terminaltexteffects-0.12.2.tar.gz", hash = "sha256:4a5eef341d538743e7ac4341cd74d47afc9d0345acdad330ed03fd0a72e41f5f", size = 164321, upload-time = "2025-10-20T20:58:26.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/93/a588ab8b15ceeef23042aa52660fb4891a0e955e92cd3aa97dcafe621720/terminaltexteffects-0.12.2-py3-none-any.whl", hash = "sha256:4b986034094007aa9a31cb1bd16d5d8fcac9755fb6a5da8f74ee7b70c0fa2d63", size = 189344, upload-time = "2025-10-20T20:58:24.425Z" }, +] + +[[package]] +name = "textual" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify", "plugins"] }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/83/c99c252c3fad2f7010ceb476a31af042eec71da441ffeef75bb590bc2e9e/textual-3.7.1.tar.gz", hash = "sha256:a76ba0c8a6c194ef24fd5c3681ebfddca55e7127c064a014128c84fbd7f5d271", size = 1604038, upload-time = "2025-07-09T09:04:45.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f1/8929fcce6dc983f7a260d0f3ddd2a69b74ba17383dbe57a7e0a9e085e8be/textual-3.7.1-py3-none-any.whl", hash = "sha256:ab5d153f4f65e77017977fa150d0376409e0acf5f1d2e25e2e4ab9de6c0d61ff", size = 691472, upload-time = "2025-07-09T09:04:43.626Z" }, +] + +[[package]] +name = "textual-serve" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-jinja2" }, + { name = "jinja2" }, + { name = "rich" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/7e/62fecc552853ec6a178cb1faa2d6f73b34d5512924770e7b08b58ff14148/textual_serve-1.1.3.tar.gz", hash = "sha256:f8f636ae2f5fd651b79d965473c3e9383d3521cdf896f9bc289709185da3f683", size = 448340, upload-time = "2025-11-01T16:22:36.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fe/108e7773349d500cf363328c3d0b7123e03feda51e310a3a5b136ac8ca71/textual_serve-1.1.3-py3-none-any.whl", hash = "sha256:207a472bc6604e725b1adab4ab8bf12f4c4dc25b04eea31e4d04731d8bf30f18", size = 447339, upload-time = "2025-11-01T16:22:35.209Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "h11", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/28/26d86ca6d2f155f31ca61e069312034a8922a5a89f5d0fc68abb7c04aad1/winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3", size = 210993, upload-time = "2025-06-06T06:44:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/46/a4/f096687e0d1877d206bc5d1f5f07ff90e00b0772d69d4559ab2b6b37090b/winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257", size = 242210, upload-time = "2025-06-06T06:44:02.366Z" }, + { url = "https://files.pythonhosted.org/packages/ff/81/46927ce4d79fc8f40f193f35204bce79eff7c496d888825a7a74d8560b6e/winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae", size = 415833, upload-time = "2025-06-06T06:44:03.379Z" }, + { url = "https://files.pythonhosted.org/packages/90/8d/d7ae0e07cd85c7768de76e8578261854f2af72bd3a8a527bb675e8ae0eda/winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022", size = 210798, upload-time = "2025-06-06T06:44:04.775Z" }, + { url = "https://files.pythonhosted.org/packages/ac/66/d05f6e6c0517654734e7f87fa1f0fbc965add9f27cc36b524d96331ab3d8/winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c", size = 242032, upload-time = "2025-06-06T06:44:06.103Z" }, + { url = "https://files.pythonhosted.org/packages/39/a5/760c8396110f6d3e4c417752da1a2bf3b89e0998329c2f10afc717ef6291/winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130", size = 415659, upload-time = "2025-06-06T06:44:07.007Z" }, + { url = "https://files.pythonhosted.org/packages/d3/54/3dd06f2341fab6abb06588a16b30e0b213b0125be7b79dafc3bdba3b334a/winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1", size = 210090, upload-time = "2025-06-06T06:44:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a1/1d7248d5c62ccbea5f3e0da64ca4529ce99c639c3be2485b6ed709f5c740/winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576", size = 241391, upload-time = "2025-06-06T06:44:09.442Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ae/6a205d8dafc79f7c242be7f940b1e0c1971fd64ab3079bda4b514aa3d714/winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195", size = 415242, upload-time = "2025-06-06T06:44:10.407Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/b7/822da8bc0b6a67cc0c3e460fef793f00c51a6fe59aa54f6bfe416519a9d9/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78", size = 105569, upload-time = "2025-06-06T07:00:05.406Z" }, + { url = "https://files.pythonhosted.org/packages/68/46/696893d3bae80751e35fb0fb8fae5e7fc94a5354dfb5e19167d415e27c66/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb", size = 114743, upload-time = "2025-06-06T07:00:06.408Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6a/a36b28739b73cc2c67050da866b063af135b5f6c071997c85a27adb6815c/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d", size = 105021, upload-time = "2025-06-06T07:00:07.299Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cf/671bf29337323cc08f9969cb32312f217d2927d29dbf2964f0dbb378cb90/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b", size = 105535, upload-time = "2025-06-06T07:00:08.146Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d5/5761a8b6dcc56957018970dd443059c8ee8a79de7b07f0b4d143f8e7dc15/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d", size = 114612, upload-time = "2025-06-06T07:00:08.984Z" }, + { url = "https://files.pythonhosted.org/packages/24/0b/7819bb102286752d3572a75d03e6a8000ffe3c6cb7aee3eb136dca383fe2/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80", size = 105017, upload-time = "2025-06-06T07:00:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/54/ff/c4a3de909a875b46fad5e9f4fd412bba48571405bfa802b878954abf128c/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c", size = 105752, upload-time = "2025-06-06T07:00:10.684Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/bfee1f0c8d188c561c5b946ab21f6a0037e60dea110e80b1d6a1d529639f/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca", size = 113356, upload-time = "2025-06-06T07:00:11.541Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/d9da9c29d36cabadef4e19c3e9ba6d2692f6a28224c81fcff757132ea0da/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148", size = 104724, upload-time = "2025-06-06T07:00:12.406Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b9/c2b0d201b8b38895809591d089a5edc37e702a23f3a6bc6e542c5e7d6dbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866", size = 89730, upload-time = "2025-06-06T07:00:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/f086c3ac17745a71d8384e1831cab0d5a7c737e1fe5cb84d7584f6c14bbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1", size = 95825, upload-time = "2025-06-06T07:00:19.385Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b5/f7f830b2da1fb7ffcaf25ce2734db0019615111f8f39e7b4d83fea4a0bd0/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f", size = 89402, upload-time = "2025-06-06T07:00:20.178Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5e/c628719e877a89f00cac7ce53f9666acbc5ed6f074130729d5d6768b63ff/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9", size = 89614, upload-time = "2025-06-06T07:00:20.952Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1a/d172d6f1c2fae53535e7f23835025cf39e3002749a0304f18a38e8ed490d/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09", size = 95783, upload-time = "2025-06-06T07:00:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/67/c1/568dfdaea62ca3b13bb70162cb292e5cd0be5bbb98b738961ddcc2edd374/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401", size = 89253, upload-time = "2025-06-06T07:00:22.603Z" }, + { url = "https://files.pythonhosted.org/packages/c9/15/ad05c28e049208c97011728e2debdb45439175f75efe357b6faa4c9ba099/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4", size = 90033, upload-time = "2025-06-06T07:00:23.421Z" }, + { url = "https://files.pythonhosted.org/packages/26/48/074779081841f6eba4987930c4e7adcec38a5985b7dffd9fecc41f39a89c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89", size = 95824, upload-time = "2025-06-06T07:00:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/aa/25/e01966033a02b2d0718710bb47ef4f6b9b5a619ca2c857e06eb5c8e3ed13/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff", size = 89311, upload-time = "2025-06-06T07:00:25.029Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/a3/449ffc2f8e4c3cfbe7f14c1b43bcaa0475fbd2e8e8bf08465399c5ea078c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46", size = 182059, upload-time = "2025-06-06T07:00:47.095Z" }, + { url = "https://files.pythonhosted.org/packages/50/d9/6ea88731df569f5c1b086daf4c3496c8d43281588e3a578ea623fef6bc43/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed", size = 187866, upload-time = "2025-06-06T07:00:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2c/ace56fd32ad07608462de0ac7df218e0bf810e4cc31f2c0fbd7f5f90ee93/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde", size = 184627, upload-time = "2025-06-06T07:00:49.087Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/349a5d958be8c0570f0a49bbb746088bcfaa81555accb57503ba01185359/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c", size = 182312, upload-time = "2025-06-06T07:00:49.974Z" }, + { url = "https://files.pythonhosted.org/packages/90/db/929ab0085ec89e46bd3a58c74b451dd770c3285dfa0cbd4f4aa4730da004/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415", size = 187768, upload-time = "2025-06-06T07:00:50.853Z" }, + { url = "https://files.pythonhosted.org/packages/a3/53/f316e2224c384178204430439f04f9b72017fe8237e341a9aebb20da8191/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7", size = 184189, upload-time = "2025-06-06T07:00:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a1/75ac783a5faee9b455fef2f53b7fef97b21ed60d52401b44c690202141e4/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557", size = 183326, upload-time = "2025-06-06T07:00:52.662Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d9/a9dcc15322d2f5c7dfd491bd7ab121e36437caf78ebfa92bc0dd0546e2ca/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d", size = 187810, upload-time = "2025-06-06T07:00:53.594Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fc/47d00af076f558267097af3050910beda6bf8a21ceaa5830bbd26fcaf85e/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984", size = 184516, upload-time = "2025-06-06T07:00:55.24Z" }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/61/2744d0e0b3fa7807149a1a36dd89abba901d6b24184d9fd5ef3f28467232/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5", size = 130040, upload-time = "2025-06-06T07:01:56.337Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f9/881b7ee8acdf3c9fe6c79d8ccd90f9246b397fc78420d55014c4ac05b822/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a", size = 142463, upload-time = "2025-06-06T07:01:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/12/db/b09dffcf1158b35d81d8d57bf19ad04293870cea5afa77943c87f1110d88/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e", size = 135871, upload-time = "2025-06-06T07:01:58.792Z" }, + { url = "https://files.pythonhosted.org/packages/a6/92/ca1fd311d96fce15fba25543a2ae3cb829744a8af548a11d74233d0e4f64/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011", size = 129898, upload-time = "2025-06-06T07:01:59.687Z" }, + { url = "https://files.pythonhosted.org/packages/03/fd/5bd5da5d7997725ba3f1995c16aa1c3362937f8ff68ad4cadfd3415eebcb/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd", size = 142361, upload-time = "2025-06-06T07:02:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/df/be/d423b63e740600e0617ddb85fba3ef99e7bbff02299fe46323bfe624a382/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817", size = 135808, upload-time = "2025-06-06T07:02:01.4Z" }, + { url = "https://files.pythonhosted.org/packages/31/3e/81642208ecd6c6c936f35a39a433c54e3f68e09d316546b8f953581ae334/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3", size = 130249, upload-time = "2025-06-06T07:02:02.237Z" }, + { url = "https://files.pythonhosted.org/packages/00/f4/a9ede5f3f0d86abfc7590726cf711133d97419b49ced372fca532e4f0696/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc", size = 141512, upload-time = "2025-06-06T07:02:03.424Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/4fad07c03124bdc3acd64f80f3bd3cc4417ea641e07bb16a9503afd3e554/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca", size = 135383, upload-time = "2025-06-06T07:02:04.312Z" }, +] + +[[package]] +name = "winrt-windows-devices-radios" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/1b/0de659ed4bb80aee28753b4431011334205637a2578481a511866a11e0cf/winrt_windows_devices_radios-3.2.1-cp310-cp310-win32.whl", hash = "sha256:f97766fd551d06c102155d51b2922f96663dee045e1f8d57177def0a2149cb78", size = 38643, upload-time = "2025-06-06T07:07:56.852Z" }, + { url = "https://files.pythonhosted.org/packages/29/fd/67c6db8a3244ecc95f85970a7b0e749cda28e26563db1274c3db36a8fbe4/winrt_windows_devices_radios-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:104b737fa1279a3b6a88ba3c6236157afc1de03c472657c45e5176ad7a209e23", size = 40295, upload-time = "2025-06-06T07:07:57.738Z" }, + { url = "https://files.pythonhosted.org/packages/15/6d/d145c7f90b01c24f4f9885d1f7d430ecaf2a2b42b6bc236701791b0b0a06/winrt_windows_devices_radios-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:55b02877d2de06ca6f0f6140611a9af9d0c65710e28f1afdeaac1040433b1837", size = 37060, upload-time = "2025-06-06T07:07:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a0/4a8b51da15de218cec04bcc1cd85b4b93bcfd8ebe50a5f0a7eee28836dc6/winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e", size = 38505, upload-time = "2025-06-06T07:07:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/de/49/ba69e3180585dbc6f3336a09fef7cba4558a6a1e7d500500f62c1478418e/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104", size = 40157, upload-time = "2025-06-06T07:07:59.948Z" }, + { url = "https://files.pythonhosted.org/packages/9c/92/64817f71a20ecf842da36dc3848f42614217688137a69c93fda8a6103155/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a", size = 36976, upload-time = "2025-06-06T07:08:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e0/4731a3c412318b2c5e74a8803a32e2fb9afc2c98368c6b61a422eb359e7e/winrt_windows_devices_radios-3.2.1-cp312-cp312-win32.whl", hash = "sha256:c3e683ce682338a5a5ed465f735e223ba7a22f16d0bbea2d070962bc7657edbb", size = 38606, upload-time = "2025-06-06T07:08:01.477Z" }, + { url = "https://files.pythonhosted.org/packages/37/8e/91464854dfc9e0be9ce8dcbe2bd6a67c19b68ab91584fc5de0f4f13e78f8/winrt_windows_devices_radios-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a116e552a3f38607b9be558fb2e7de9b4450d1f9080069944d74d80cdda1873e", size = 40172, upload-time = "2025-06-06T07:08:02.214Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0d/1bd62f606b6c4dfa936fccc4712be5506a40fc5d1b7177c3d3cbcaf30972/winrt_windows_devices_radios-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4c28822f9251c9d547324f596b5c2581f050254ded05e5b786c650a3502744c1", size = 36989, upload-time = "2025-06-06T07:08:03.295Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/d387332c4378b41f87211b7dc40a4cfc6b7047dc227448aaa207624fc911/winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b", size = 111969, upload-time = "2025-06-06T07:10:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/046c1e2424627c3db66d764871186de4d26936e8a138d6bf04dc143e4606/winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39", size = 118695, upload-time = "2025-06-06T07:10:56.782Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2e/2463bc4ad984836fb3ecf1abac62df67bc5cabab004cad09b828b86ed51b/winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4", size = 109690, upload-time = "2025-06-06T07:10:57.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/36/09b9757f7cbf269e67008ea2ad188a44f974c94c9b49ebf0b52d1a8c4069/winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437", size = 111944, upload-time = "2025-06-06T07:10:58.496Z" }, + { url = "https://files.pythonhosted.org/packages/05/a5/216d66df6bdcee58eb3877fabc1544337e23f850bf9f93838db7f5698371/winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e", size = 118465, upload-time = "2025-06-06T07:10:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/be/ca/48ca8b5bc5be5c7a5516c9e1d9a21861b4217e1b4ee57923aab6f13fa411/winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671", size = 109609, upload-time = "2025-06-06T07:11:00.54Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/495e304ddedd5ff2f196efbde906265cb75ade4d79e2937837f72ef654a0/winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b", size = 112169, upload-time = "2025-06-06T07:11:01.438Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5e/b5059e4ece095351c496c9499783130c302d25e353c18031d5231b1b3b3c/winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9", size = 118668, upload-time = "2025-06-06T07:11:02.475Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/acbcb3ef07b1b67e2de4afab9176a5282cfd775afd073efe6828dfc65ace/winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37", size = 109671, upload-time = "2025-06-06T07:11:03.538Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/26/ed3d35ea262999d28be957c35a32e93360eac0ef9f14e75d32cd6b5c6a37/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389", size = 59880, upload-time = "2025-06-06T07:11:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/b4a1aeba2d13c1f2ad3d851d5092b8397c05f34fb318d6a7d499f5b5720b/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6", size = 70650, upload-time = "2025-06-06T07:11:11.396Z" }, + { url = "https://files.pythonhosted.org/packages/9f/74/f8a4a29202da24f2af2c4a8f515b0a44fe46bc4d25b3d54ea2249e980bd3/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6", size = 59216, upload-time = "2025-06-06T07:11:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/7e4a75c62e86bedf9458b7ec8dfed74cff3236e0b4b2288f95967d5cc4d2/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7", size = 59693, upload-time = "2025-06-06T07:11:13.388Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/049db1d95fdfc0c8451dc6db17442ed4e6b2aba361c425c0bb8dc8c98c4a/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89", size = 70828, upload-time = "2025-06-06T07:11:14.54Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6b/a04974f5555c86452e54c19d063d9fd45f0fe9f2a6858e7fe12c639043fb/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523", size = 59051, upload-time = "2025-06-06T07:11:15.379Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0b/7802349391466d3f7e8f62f588f36a1a0b6560abfcdbdaa426fe21d322b4/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6", size = 60060, upload-time = "2025-06-06T07:11:16.173Z" }, + { url = "https://files.pythonhosted.org/packages/37/94/5b888713e472746635a382e523513ab1b8200af55c5b56bc70e1e4369115/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b", size = 69058, upload-time = "2025-06-06T07:11:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/829273622c9b37c67b97f187b92be318404f7d33db045e31d72b7d50f54c/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84", size = 58793, upload-time = "2025-06-06T07:11:17.837Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/4d/a0d806f4664b9bcf525bd31dcdf1f9520cc14f033e897dc7f7dd4ad4eb77/winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003", size = 127791, upload-time = "2025-06-06T14:01:56.192Z" }, + { url = "https://files.pythonhosted.org/packages/99/2c/00baa87041a3d92a3cc5230d4033e995a52740e9c08fcd9f7bde93cb979f/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00", size = 132608, upload-time = "2025-06-06T14:01:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ed03e864aa8eaaec964d5bbc95baccf738275ae6cc88600db66ecb5adaf4/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6", size = 128495, upload-time = "2025-06-06T14:01:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/a9e0dc03434aa29e6b5c83067e988cd5934adf830cd9f87cbbc06569ca32/winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69", size = 127509, upload-time = "2025-06-06T14:01:58.971Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/6c9c21b5e75ff5927a130da9eaf5ab628dfa1f93b64c181f0193706cbd6c/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5", size = 132491, upload-time = "2025-06-06T14:01:59.788Z" }, + { url = "https://files.pythonhosted.org/packages/38/ca/d0a02045d445cbf1029d65f01b487fdded5b333c0367a8bae0565b3def00/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780", size = 128577, upload-time = "2025-06-06T14:02:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/e7/7d3f2a4a442f264e05cab2bdf20ed1b95cb3f753bd1b0f277f2b49fb8335/winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78", size = 127787, upload-time = "2025-06-06T14:02:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2f/cc36f475f8af293f40e2c2a5d6c2e75a189c2c2d4d01ecb3551578518c79/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc", size = 131849, upload-time = "2025-06-06T14:02:03.09Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/896fb734f7456910ec412f3f3adfdc3f0dc3134864a496d5b120592f3bfd/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307", size = 128144, upload-time = "2025-06-06T14:02:03.946Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/df/f1c7a3de0831cd83194f1a85c5bb431b13f81e6b45079314c86d1c4ef3f2/yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12", size = 129057, upload-time = "2026-05-19T21:27:47.564Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/7daafb32dd7562bf45b1ce56562e7e1a9146f6479b6456873eb8a3413c40/yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0", size = 91545, upload-time = "2026-05-19T21:27:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8f/7b3ec212f1ea0683f55f978e3246bc313c38818664edfc97a9f349a4901e/yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75", size = 91380, upload-time = "2026-05-19T21:27:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1b/8bafab7db23b0567ae9db749099b329d91e3b82bc6028b2050ba583e116c/yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727", size = 105957, upload-time = "2026-05-19T21:27:53.98Z" }, + { url = "https://files.pythonhosted.org/packages/7f/77/21030c2f8d21d21559719beafc772ada2014be933418ed1eaed9cc800e42/yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413", size = 97242, upload-time = "2026-05-19T21:27:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/f9ea63d1b6aa910a866e089d871fff6cbd49caab29b86b35221a62dfa0d5/yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9", size = 114719, upload-time = "2026-05-19T21:27:58.037Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/04e0ee98ac58a249ea7ed75223f5f901ba81a834f0b4921b58e5cec11757/yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2", size = 112140, upload-time = "2026-05-19T21:27:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/ad/0b9cc9f38a7324a7eb1d80f834eaa5283d17e9271bbda3186e598dddaeac/yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90", size = 106721, upload-time = "2026-05-19T21:28:02.586Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/a52478ebfc66ec989e085c6ae038b9f1bfa4190baa193b133b669c709e2f/yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643", size = 106478, upload-time = "2026-05-19T21:28:04.523Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/5508530fea8472542de00013ae280765fc938ee196fc4030c43a498afb36/yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac", size = 105423, upload-time = "2026-05-19T21:28:06.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/f1/ece28505e9628e8b756e11bb4f28864a17cc33b6b44db4d2aaf0622bf630/yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f", size = 99878, upload-time = "2026-05-19T21:28:08.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/fb5d34529b46dd84013afcfb30b8d2bc2832ed03d412736f577d604fa393/yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36", size = 114025, upload-time = "2026-05-19T21:28:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/ff9d31aaab024f7a251c0ed308a98ae29bf9f7dc344e78f28b1322431ca2/yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a", size = 105613, upload-time = "2026-05-19T21:28:12.784Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/3296fb3f3ecd52bf9ae6c16b0895c1cda7e9170a2083861552b683f70264/yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53", size = 111665, upload-time = "2026-05-19T21:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/77aa6ddaca4fbf42e45e675a465c43956dd40702281049975a2aa04eae59/yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342", size = 106914, upload-time = "2026-05-19T21:28:15.893Z" }, + { url = "https://files.pythonhosted.org/packages/d8/02/7611f22cd1d4ed7373eb7f9ee21fde1046edba2e7c0e514880d760352f48/yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4", size = 92658, upload-time = "2026-05-19T21:28:17.471Z" }, + { url = "https://files.pythonhosted.org/packages/91/00/671d0add79938127292839ae44506ce2f7fe8909c72d5a931864f128fd0b/yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39", size = 87887, upload-time = "2026-05-19T21:28:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index ee49e5ee37..9e99bfd46e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -633,6 +633,7 @@ ignore = [ "uv.lock", "packages/dimos-gpd-grasp-demo/pixi.lock", "packages/dimos-gpd-grasp-demo/uv.lock", + "packages/dimos-robosuite-sidecar/uv.lock", "packages/dimos-gpd-worker-demo/pixi.lock", "packages/dimos-gpd-worker-demo/uv.lock", "*/package-lock.json", diff --git a/scripts/benchmarks/demo_agentic_manipulation_robosuite.py b/scripts/benchmarks/demo_agentic_manipulation_robosuite.py deleted file mode 100644 index 9c1ee0e36d..0000000000 --- a/scripts/benchmarks/demo_agentic_manipulation_robosuite.py +++ /dev/null @@ -1,920 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Run layer-2 Robosuite validation through AgenticManipulationModule. - -The script keeps Robosuite out of the DimOS process by launching the Robosuite -sidecar subprocess, then builds a DimOS stack with the benchmark-runtime SHM -adapter, ControlCoordinator, ManipulationModule, and AgenticManipulationModule. -It calls the agent-facing module API directly and writes artifacts describing -the episode, runtime plan, API results, motor trace, score, sidecar log, and -cleanup status. -""" - -from __future__ import annotations - -import argparse -from collections.abc import Callable -from dataclasses import dataclass -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import sys -import threading -import time - -REPO_ROOT = Path(__file__).resolve().parents[2] -PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" -ROBOSUITE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src" - -for package_src in (PROTOCOL_SRC, ROBOSUITE_SIDECAR_SRC): - sys.path.insert(0, str(package_src)) - -from dimos_runtime_protocol import EpisodeResetRequest, MotorActionFrame, StepRequest - -from dimos.benchmark.runtime.artifacts import write_json -from dimos.benchmark.runtime.config import ( - BenchmarkEpisodeConfig, - ResolvedRuntimePlan, - resolve_runtime_plan, -) -from dimos.control.components import HardwareComponent, HardwareType -from dimos.control.coordinator import ControlCoordinator, TaskConfig -from dimos.core.coordination.blueprints import autoconnect -from dimos.core.coordination.module_coordinator import ModuleCoordinator -from dimos.hardware.whole_body.spec import MotorState, WholeBodyConfig -from dimos.manipulation.agentic_manipulation_module import AgenticManipulationModule -from dimos.manipulation.manipulation_module import ManipulationModule -from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.robot.config import GripperConfig, RobotConfig -from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient -from dimos.simulation.runtime_client.shm_motor import MotorShmOwner - - -@dataclass(frozen=True) -class RuntimeRobotStackConfig: - """DimOS stack config derived from a Robosuite runtime plan.""" - - robot: RobotConfig - hardware: HardwareComponent - task: TaskConfig - model: RobotModelConfig - - -@dataclass(frozen=True) -class ApiCallRecord: - """Summary of one direct AgenticManipulationModule API call.""" - - name: str - success: bool - message: str - duration_ms: float | None - - -def _load_config(path: Path) -> BenchmarkEpisodeConfig: - return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) - - -def _ensure_drake_available() -> None: - if importlib.util.find_spec("pydrake") is None: - raise RuntimeError( - "Agentic manipulation Robosuite demo requires the DimOS manipulation planning " - "extra because ManipulationModule uses the Drake planning backend in this demo. " - "Run: uv run --extra manipulation --with robosuite python " - "scripts/benchmarks/demo_agentic_manipulation_robosuite.py" - ) - - -def _sidecar_env() -> dict[str, str]: - env = dict(os.environ) - existing = env.get("PYTHONPATH", "") - paths = [str(PROTOCOL_SRC), str(ROBOSUITE_SIDECAR_SRC)] - if existing: - paths.append(existing) - env["PYTHONPATH"] = os.pathsep.join(paths) - return env - - -def _start_robosuite_sidecar(config: BenchmarkEpisodeConfig) -> subprocess.Popen[str]: - command = [ - sys.executable, - "-m", - "dimos_robosuite_sidecar.server", - "--host", - config.runtime_host, - "--port", - str(config.runtime_port), - "--env-name", - config.env_name, - "--robot-id", - config.robot_id, - "--robot-model", - config.robot_model, - "--controller", - config.controller, - "--control-freq", - str(config.control_step_hz), - "--horizon", - str(config.horizon), - "--camera-name", - config.camera_name, - "--seed", - str(config.seed) if config.seed is not None else "0", - ] - if config.visualize: - command.append("--visualize") - return subprocess.Popen( - command, - cwd=REPO_ROOT, - env=_sidecar_env(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - -def _wait_sidecar_healthy( - sidecar: subprocess.Popen[str], - client: RuntimeSidecarClient, - timeout_s: float, -) -> object: - deadline = time.monotonic() + timeout_s - last_error = "" - while time.monotonic() < deadline: - if sidecar.poll() is not None: - raise RuntimeError("Robosuite sidecar exited before becoming healthy") - try: - return client.health() - except Exception as exc: - last_error = str(exc) - time.sleep(0.1) - raise RuntimeError(f"Robosuite sidecar did not become healthy: {last_error}") - - -def _command_frame(owner: MotorShmOwner, robot_id: str) -> tuple[int, MotorActionFrame]: - sequence, commands = owner.read_commands() - return sequence, MotorActionFrame( - robot_id=robot_id, - names=owner.motor_names, - q=[command.q for command in commands], - dq=[command.dq for command in commands], - kp=[command.kp for command in commands], - kd=[command.kd for command in commands], - tau=[command.tau for command in commands], - sequence=sequence, - ) - - -def _local_motor_names(plan: ResolvedRuntimePlan) -> list[str]: - prefix = f"{plan.robot_id}/" - local_names: list[str] = [] - for motor_name in plan.motor_names: - if motor_name.startswith(prefix): - local_names.append(motor_name[len(prefix) :]) - else: - local_names.append(motor_name) - return local_names - - -def _write_minimal_urdf(path: Path, joint_names: list[str]) -> None: - links = [' '] - joints: list[str] = [] - parent = "base_link" - for index, joint_name in enumerate(joint_names, start=1): - child = f"link_{index}" - links.append(f' ') - joints.extend( - [ - f' ', - f' ', - f' ', - ' ', - ' ', - ' ', - " ", - ] - ) - parent = child - xml = [ - '', - '', - *links, - *joints, - "", - "", - ] - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("\n".join(xml)) - - -def _derive_runtime_robot_stack( - plan: ResolvedRuntimePlan, - artifact_dir: Path, -) -> RuntimeRobotStackConfig: - local_joint_names = _local_motor_names(plan) - if len(local_joint_names) != len(plan.motor_names): - raise ValueError("local/global motor name count mismatch") - - model_path = artifact_dir / "runtime_robot.urdf" - _write_minimal_urdf(model_path, local_joint_names) - gripper_joint = local_joint_names[-1] - robot = RobotConfig( - name=plan.robot_id, - model_path=model_path, - joint_names=local_joint_names, - base_link="base_link", - end_effector_link=f"link_{len(local_joint_names)}", - adapter_type="benchmark_runtime", - address=plan.shm_key, - adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, - home_joints=[0.0] * len(local_joint_names), - gripper=GripperConfig(type="runtime_motor", joints=[gripper_joint]), - ) - hardware = HardwareComponent( - hardware_id=plan.robot_id, - hardware_type=HardwareType.WHOLE_BODY, - joints=plan.motor_names, - adapter_type="benchmark_runtime", - address=plan.shm_key, - adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, - wb_config=WholeBodyConfig( - kp=tuple(40.0 for _ in plan.motor_names), - kd=tuple(3.0 for _ in plan.motor_names), - ), - ) - return RuntimeRobotStackConfig( - robot=robot, - hardware=hardware, - task=robot.to_task_config(task_type="trajectory"), - model=robot.to_robot_model_config(), - ) - - -class SidecarSteppingLoop: - """Background SHM-to-sidecar loop for blocking manipulation API calls.""" - - def __init__( - self, - *, - client: RuntimeSidecarClient, - owner: MotorShmOwner, - plan: ResolvedRuntimePlan, - ) -> None: - self._client = client - self._owner = owner - self._plan = plan - self._stop_event = threading.Event() - self._thread = threading.Thread( - target=self._run, name="robosuite-sidecar-step", daemon=True - ) - self._trace: list[dict[str, object]] = [] - self._error: str | None = None - - @property - def trace(self) -> list[dict[str, object]]: - return list(self._trace) - - @property - def error(self) -> str | None: - return self._error - - @property - def tick_count(self) -> int: - return len(self._trace) - - @property - def is_alive(self) -> bool: - return self._thread.is_alive() - - def start(self) -> None: - self._thread.start() - - def stop(self) -> None: - self._stop_event.set() - self._thread.join(timeout=5.0) - - def _run(self) -> None: - tick = 0 - period_s = 1.0 / float(self._plan.control_step_hz) - while not self._stop_event.is_set() and tick < self._plan.ticks: - try: - command_sequence, action = _command_frame(self._owner, self._plan.robot_id) - response = self._client.step( - StepRequest(episode_id=self._plan.episode_id, tick_id=tick, action=action) - ) - self._owner.write_state( - [ - MotorState( - q=response.motor_state.q[i], - dq=response.motor_state.dq[i], - tau=response.motor_state.tau[i], - ) - for i in range(len(self._plan.motor_names)) - ], - sequence=response.motor_state.sequence, - ) - self._trace.append( - { - "tick": tick, - "command_sequence": command_sequence, - "state_sequence": response.motor_state.sequence, - "command_q": action.q, - "state_q": response.motor_state.q, - "reward": response.reward, - "done": response.done, - "success": response.success, - } - ) - if response.done: - break - tick += 1 - time.sleep(period_s) - except Exception as exc: - self._error = str(exc) - break - - -def _call_agentic_api( - name: str, - call: Callable[[], object], - records: list[ApiCallRecord], -) -> None: - result = call() - success_attr = getattr(result, "success", False) - success = bool(success_attr) - message_attr = getattr(result, "message", "") - duration_attr = getattr(result, "duration_ms", None) - records.append( - ApiCallRecord( - name=name, - success=success, - message=str(message_attr), - duration_ms=float(duration_attr) if isinstance(duration_attr, int | float) else None, - ) - ) - if not success: - raise RuntimeError(f"AgenticManipulationModule.{name} failed: {message_attr}") - - -def _wait_for_sidecar_ticks( - stepping_loop: SidecarSteppingLoop, - *, - seconds: float, - control_step_hz: float, - label: str, -) -> None: - """Let Robosuite advance for a visible amount of simulated time.""" - - if seconds <= 0.0: - return - start_tick = stepping_loop.tick_count - target_ticks = max(1, round(seconds * control_step_hz)) - deadline = time.monotonic() + max(seconds * 4.0, seconds + 2.0) - while stepping_loop.tick_count - start_tick < target_ticks: - if stepping_loop.error is not None: - raise RuntimeError( - f"sidecar stepping loop failed during {label}: {stepping_loop.error}" - ) - if not stepping_loop.is_alive: - raise RuntimeError( - f"sidecar stepping loop ended while waiting after {label}; " - "increase --ticks or reduce the visual pause durations" - ) - if time.monotonic() > deadline: - raise RuntimeError(f"timed out waiting for Robosuite ticks after {label}") - time.sleep(0.02) - - -def _small_offset_target(current_positions: list[float], offset: float) -> str: - target = list(current_positions) - if target: - target[0] += offset - return ", ".join(f"{value:.4f}" for value in target) - - -def _latest_state_positions(stepping_loop: SidecarSteppingLoop, motor_count: int) -> list[float]: - trace = stepping_loop.trace - if not trace: - return [0.0] * motor_count - state_q = trace[-1].get("state_q", []) - if not isinstance(state_q, list): - return [0.0] * motor_count - positions = [float(value) for value in state_q] - if len(positions) != motor_count: - return [0.0] * motor_count - return positions - - -def _hardware_summary(hardware: HardwareComponent) -> dict[str, object]: - wb_config = hardware.wb_config - return { - "hardware_id": hardware.hardware_id, - "hardware_type": hardware.hardware_type.value, - "joints": hardware.joints, - "adapter_type": hardware.adapter_type, - "address": str(hardware.address) if hardware.address is not None else None, - "auto_enable": hardware.auto_enable, - "gripper_joints": hardware.gripper_joints, - "domain_id": hardware.domain_id, - "adapter_kwargs": hardware.adapter_kwargs, - "wb_config": { - "kp": list(wb_config.kp) if wb_config.kp is not None else None, - "kd": list(wb_config.kd) if wb_config.kd is not None else None, - } - if wb_config is not None - else None, - "derivation_note": ( - "Constructed directly as WHOLE_BODY because RobotConfig.to_hardware_component() " - "derives MANIPULATOR hardware, while the benchmark runtime adapter exposes a " - "whole-body SHM motor plane. TaskConfig and RobotModelConfig still derive from RobotConfig." - ), - } - - -def _gripper_summary(gripper: GripperConfig | None) -> dict[str, object] | None: - if gripper is None: - return None - return { - "type": gripper.type, - "joints": gripper.joints, - "collision_exclusions": [list(pair) for pair in gripper.collision_exclusions], - "open_position": gripper.open_position, - "close_position": gripper.close_position, - } - - -def _robot_config_summary(robot: RobotConfig) -> dict[str, object]: - return { - "name": robot.name, - "model_path": str(robot.model_path) if robot.model_path is not None else None, - "end_effector_link": robot.end_effector_link, - "height_clearance": robot.height_clearance, - "width_clearance": robot.width_clearance, - "adapter_type": robot.adapter_type, - "address": robot.address, - "adapter_kwargs": robot.adapter_kwargs, - "auto_enable": robot.auto_enable, - "joint_names": robot.joint_names, - "base_link": robot.base_link, - "home_joints": robot.home_joints, - "base_pose": robot.base_pose, - "strip_model_world_joint": robot.strip_model_world_joint, - "max_velocity": robot.max_velocity, - "max_acceleration": robot.max_acceleration, - "pre_grasp_offset": robot.pre_grasp_offset, - "gripper": _gripper_summary(robot.gripper), - "package_paths": {key: str(path) for key, path in robot.package_paths.items()}, - "xacro_args": robot.xacro_args, - "auto_convert_meshes": robot.auto_convert_meshes, - "srdf_path": str(robot.srdf_path) if robot.srdf_path is not None else None, - "tf_extra_links": robot.tf_extra_links, - "task_type": robot.task_type, - "task_priority": robot.task_priority, - "collision_exclusion_pairs": [list(pair) for pair in robot.collision_exclusion_pairs], - } - - -def _task_summary(task: TaskConfig) -> dict[str, object]: - return { - "name": task.name, - "type": task.type, - "joint_names": task.joint_names, - "priority": task.priority, - "auto_start": task.auto_start, - "params": task.params, - } - - -def _robot_model_summary(model: RobotModelConfig) -> dict[str, object]: - return { - "name": model.name, - "model_path": str(model.model_path), - "srdf_path": str(model.srdf_path) if model.srdf_path is not None else None, - "base_pose": { - "position": [ - model.base_pose.position.x, - model.base_pose.position.y, - model.base_pose.position.z, - ], - "orientation": [ - model.base_pose.orientation.x, - model.base_pose.orientation.y, - model.base_pose.orientation.z, - model.base_pose.orientation.w, - ], - "frame_id": model.base_pose.frame_id, - "ts": model.base_pose.ts, - }, - "strip_model_world_joint": model.strip_model_world_joint, - "joint_names": model.joint_names, - "end_effector_link": model.end_effector_link, - "base_link": model.base_link, - "planning_groups": [ - { - "name": group.name, - "joint_names": list(group.joint_names), - "base_link": group.base_link, - "tip_link": group.tip_link, - "source": group.source, - } - for group in model.planning_groups - ], - "package_paths": {key: str(path) for key, path in model.package_paths.items()}, - "joint_limits_lower": model.joint_limits_lower, - "joint_limits_upper": model.joint_limits_upper, - "velocity_limits": model.velocity_limits, - "auto_convert_meshes": model.auto_convert_meshes, - "xacro_args": model.xacro_args, - "collision_exclusion_pairs": [list(pair) for pair in model.collision_exclusion_pairs], - "max_velocity": model.max_velocity, - "max_acceleration": model.max_acceleration, - "coordinator_task_name": model.coordinator_task_name, - "gripper_hardware_id": model.gripper_hardware_id, - "tf_extra_links": model.tf_extra_links, - "home_joints": model.home_joints, - "pre_grasp_offset": model.pre_grasp_offset, - "fallback_model_note": ( - "Script-generated minimal URDF for runtime validation only. Joint limits are " - "conservative to cover Robosuite reset states until sidecar metadata can provide " - "authoritative model limits." - ), - } - - -def _api_call_summary(records: list[ApiCallRecord]) -> list[dict[str, object]]: - return [ - { - "name": record.name, - "success": record.success, - "message": record.message, - "duration_ms": record.duration_ms, - } - for record in records - ] - - -def _write_json_artifact( - path: Path, - payload: object, - errors: dict[str, str], -) -> None: - try: - write_json(path, payload) - except Exception as exc: - errors[path.name] = str(exc) - - -def _write_available_artifacts( - *, - artifact_dir: Path, - config: BenchmarkEpisodeConfig, - health: object | None, - description: object | None, - plan: ResolvedRuntimePlan | None, - reset: object | None, - stack_config: RuntimeRobotStackConfig | None, - api_calls: list[ApiCallRecord], - stepping_loop: SidecarSteppingLoop | None, - score: object | None, - failure: str | None, -) -> dict[str, str]: - """Write every validation artifact available on success or failure.""" - - errors: dict[str, str] = {} - _write_json_artifact(artifact_dir / "episode_config.json", config, errors) - if health is not None: - _write_json_artifact(artifact_dir / "health.json", health, errors) - if description is not None: - _write_json_artifact(artifact_dir / "runtime_description.json", description, errors) - if plan is not None: - _write_json_artifact(artifact_dir / "resolved_runtime_plan.json", plan, errors) - if reset is not None: - _write_json_artifact(artifact_dir / "reset_response.json", reset, errors) - if stack_config is not None: - _write_json_artifact( - artifact_dir / "runtime_robot_config.json", - _robot_config_summary(stack_config.robot), - errors, - ) - _write_json_artifact( - artifact_dir / "stack_config_summary.json", - { - "hardware": _hardware_summary(stack_config.hardware), - "task": _task_summary(stack_config.task), - "robot_model": _robot_model_summary(stack_config.model), - }, - errors, - ) - _write_json_artifact( - artifact_dir / "api_call_summary.json", _api_call_summary(api_calls), errors - ) - _write_json_artifact( - artifact_dir / "motor_trace.json", - stepping_loop.trace if stepping_loop is not None else [], - errors, - ) - _write_json_artifact( - artifact_dir / "score.json", - score if score is not None else {"available": False, "reason": "not reached"}, - errors, - ) - if failure is not None: - _write_json_artifact(artifact_dir / "failure.json", {"reason": failure}, errors) - return errors - - -def _build_stack(stack_config: RuntimeRobotStackConfig, tick_rate: float) -> ModuleCoordinator: - blueprint = autoconnect( - ControlCoordinator.blueprint( - tick_rate=tick_rate, - publish_joint_state=True, - hardware=[stack_config.hardware], - tasks=[stack_config.task], - ), - ManipulationModule.blueprint( - robots=[stack_config.model], - world_backend="drake", - planner_name="rrt_connect", - coordinator_rpc_timeout=10.0, - ), - AgenticManipulationModule.blueprint(), - ) - return ModuleCoordinator.build(blueprint) - - -def run_demo_config( - config: BenchmarkEpisodeConfig, - *, - move_offset: float = 0.25, - settle_s: float = 0.25, - primitive_pause_s: float = 1.0, - post_demo_s: float = 2.0, -) -> Path: - return _run_demo( - config, - move_offset=move_offset, - settle_s=settle_s, - primitive_pause_s=primitive_pause_s, - post_demo_s=post_demo_s, - ) - - -def run_demo(config_path: Path) -> Path: - return run_demo_config(_load_config(config_path)) - - -def _run_demo( - config: BenchmarkEpisodeConfig, - *, - move_offset: float, - settle_s: float, - primitive_pause_s: float, - post_demo_s: float, -) -> Path: - _ensure_drake_available() - - artifact_dir = (REPO_ROOT / config.artifact_dir).resolve() - artifact_dir.mkdir(parents=True, exist_ok=True) - client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") - sidecar: subprocess.Popen[str] | None = None - owner: MotorShmOwner | None = None - coordinator: ModuleCoordinator | None = None - stepping_loop: SidecarSteppingLoop | None = None - health: object | None = None - description: object | None = None - plan: ResolvedRuntimePlan | None = None - reset: object | None = None - stack_config: RuntimeRobotStackConfig | None = None - score: object | None = None - api_calls: list[ApiCallRecord] = [] - failure: str | None = None - cleanup_status: dict[str, object] = { - "module_coordinator_stopped": False, - "stepping_loop_stopped": False, - "shm_unlinked": False, - "sidecar_stopped": False, - } - sidecar_output = "" - try: - sidecar = _start_robosuite_sidecar(config) - try: - health = _wait_sidecar_healthy(sidecar, client, timeout_s=20.0) - except RuntimeError as exc: - raise RuntimeError( - "Robosuite sidecar did not become healthy. If this environment does not " - "have Robosuite installed, run this script in the Robosuite sidecar env." - ) from exc - description = client.describe() - plan = resolve_runtime_plan(config, description) - reset = client.reset( - EpisodeResetRequest(episode_id=plan.episode_id, task_id=plan.task_id, seed=config.seed) - ) - stack_config = _derive_runtime_robot_stack(plan, artifact_dir) - - owner = MotorShmOwner(plan.shm_key, plan.motor_names) - owner.write_state([MotorState(q=0.0) for _ in plan.motor_names], sequence=0) - - coordinator = _build_stack(stack_config, float(plan.control_step_hz)) - agentic_module = coordinator.get_instance(AgenticManipulationModule) - stepping_loop = SidecarSteppingLoop(client=client, owner=owner, plan=plan) - stepping_loop.start() - - # Allow the coordinator to publish initial joint state into ManipulationModule. - _wait_for_sidecar_ticks( - stepping_loop, - seconds=settle_s, - control_step_hz=float(plan.control_step_hz), - label="initial state propagation", - ) - - _call_agentic_api("get_robot_state", agentic_module.get_robot_state, api_calls) - _call_agentic_api("open_gripper", agentic_module.open_gripper, api_calls) - _wait_for_sidecar_ticks( - stepping_loop, - seconds=primitive_pause_s, - control_step_hz=float(plan.control_step_hz), - label="open_gripper", - ) - _call_agentic_api("close_gripper", agentic_module.close_gripper, api_calls) - _wait_for_sidecar_ticks( - stepping_loop, - seconds=primitive_pause_s, - control_step_hz=float(plan.control_step_hz), - label="close_gripper", - ) - target_joints = _small_offset_target( - _latest_state_positions(stepping_loop, len(plan.motor_names)), move_offset - ) - _call_agentic_api( - "move_to_joints", - lambda: agentic_module.move_to_joints(target_joints), - api_calls, - ) - _wait_for_sidecar_ticks( - stepping_loop, - seconds=post_demo_s, - control_step_hz=float(plan.control_step_hz), - label="move_to_joints hold", - ) - if stepping_loop.error is not None: - raise RuntimeError(f"sidecar stepping loop failed: {stepping_loop.error}") - - try: - score = client.score() - except Exception as exc: - score = {"available": False, "error": str(exc)} - - return artifact_dir - except Exception as exc: - failure = str(exc) - raise - finally: - if stepping_loop is not None: - try: - stepping_loop.stop() - cleanup_status["stepping_loop_stopped"] = True - cleanup_status["stepping_loop_error"] = stepping_loop.error - except Exception as exc: - cleanup_status["stepping_loop_stop_error"] = str(exc) - if coordinator is not None: - try: - coordinator.stop() - cleanup_status["module_coordinator_stopped"] = True - except Exception as exc: - cleanup_status["module_coordinator_error"] = str(exc) - if owner is not None: - try: - owner.close() - owner.unlink() - cleanup_status["shm_unlinked"] = True - except Exception as exc: - cleanup_status["shm_error"] = str(exc) - if sidecar is not None: - sidecar.terminate() - try: - sidecar_output, _ = sidecar.communicate(timeout=2.0) - except subprocess.TimeoutExpired: - sidecar.kill() - sidecar_output, _ = sidecar.communicate(timeout=2.0) - cleanup_status["sidecar_returncode"] = sidecar.returncode - cleanup_status["sidecar_stopped"] = sidecar.returncode is not None - else: - cleanup_status["sidecar_returncode"] = None - sidecar_log = artifact_dir / "robosuite_sidecar.log" - sidecar_log.parent.mkdir(parents=True, exist_ok=True) - sidecar_log.write_text(sidecar_output) - artifact_errors = _write_available_artifacts( - artifact_dir=artifact_dir, - config=config, - health=health, - description=description, - plan=plan, - reset=reset, - stack_config=stack_config, - api_calls=api_calls, - stepping_loop=stepping_loop, - score=score, - failure=failure, - ) - cleanup_status["artifact_write_errors"] = artifact_errors - write_json(sidecar_log.parent / "cleanup_status.json", cleanup_status) - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--config", - type=Path, - default=REPO_ROOT - / "dimos" - / "benchmark" - / "runtime" - / "configs" - / "robosuite_panda_lift.json", - ) - parser.add_argument("--ticks", type=int, default=None, help="Override configured tick count.") - parser.add_argument("--horizon", type=int, default=None, help="Override episode horizon.") - parser.add_argument( - "--move-offset", - type=float, - default=0.25, - help="Safe offset applied to the first motor for the direct move_to_joints call.", - ) - parser.add_argument( - "--settle-s", - type=float, - default=0.25, - help="Seconds of sidecar stepping before the first API call so initial state propagates.", - ) - parser.add_argument( - "--primitive-pause-s", - type=float, - default=1.0, - help="Seconds to keep stepping after open_gripper and close_gripper for visual inspection.", - ) - parser.add_argument( - "--post-demo-s", - type=float, - default=2.0, - help="Seconds to keep the viewer alive after move_to_joints completes.", - ) - visual_group = parser.add_mutually_exclusive_group() - visual_group.add_argument( - "--visual", - dest="visualize", - action="store_true", - default=True, - help="Open the Robosuite viewer through the sidecar. This is the default.", - ) - visual_group.add_argument( - "--headless", - dest="visualize", - action="store_false", - help="Disable the Robosuite viewer for CI or non-GUI environments.", - ) - args = parser.parse_args() - try: - config = _load_config(args.config) - updates: dict[str, object] = {"visualize": args.visualize} - if args.ticks is not None: - updates["ticks"] = args.ticks - else: - visible_seconds = ( - args.settle_s + (2.0 * args.primitive_pause_s) + args.post_demo_s + 1.0 - ) - updates["ticks"] = max(config.ticks, int(config.control_step_hz * visible_seconds)) - if args.horizon is not None: - updates["horizon"] = args.horizon - else: - updates["horizon"] = max(config.horizon, int(updates["ticks"]) + 1) - if updates: - config = BenchmarkEpisodeConfig.model_validate({**config.model_dump(), **updates}) - artifact_dir = run_demo_config( - config, - move_offset=args.move_offset, - settle_s=args.settle_s, - primitive_pause_s=args.primitive_pause_s, - post_demo_s=args.post_demo_s, - ) - except Exception as exc: - print(json.dumps({"ok": False, "reason": str(exc)}, indent=2)) - sys.exit(2) - print(json.dumps({"ok": True, "artifact_dir": str(artifact_dir)}, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/scripts/benchmarks/demo_fake_runtime_sidecar.py b/scripts/benchmarks/demo_fake_runtime_sidecar.py index a312ae5a15..324848799a 100644 --- a/scripts/benchmarks/demo_fake_runtime_sidecar.py +++ b/scripts/benchmarks/demo_fake_runtime_sidecar.py @@ -13,17 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Run the fake runtime sidecar smoke demo with a real ControlCoordinator.""" +"""Run the fake simulator runtime module smoke demo.""" from __future__ import annotations import argparse import json -import os from pathlib import Path -import subprocess import sys -import time REPO_ROOT = Path(__file__).resolve().parents[2] PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" @@ -32,6 +29,7 @@ for package_src in (PROTOCOL_SRC, FAKE_SIDECAR_SRC): sys.path.insert(0, str(package_src)) +from dimos_fake_runtime_sidecar.module import FakeRuntimeModule from dimos_runtime_protocol import EpisodeResetRequest, MotorActionFrame, StepRequest from dimos.benchmark.runtime.artifacts import write_json @@ -39,64 +37,12 @@ BenchmarkEpisodeConfig, resolve_runtime_plan, ) -from dimos.control.components import HardwareComponent, HardwareType -from dimos.control.coordinator import ControlCoordinator, TaskConfig -from dimos.hardware.whole_body.spec import MotorState -from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient -from dimos.simulation.runtime_client.shm_motor import MotorShmOwner def _load_config(path: Path) -> BenchmarkEpisodeConfig: return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) -def _sidecar_env() -> dict[str, str]: - env = dict(os.environ) - existing = env.get("PYTHONPATH", "") - paths = [str(PROTOCOL_SRC), str(FAKE_SIDECAR_SRC)] - if existing: - paths.append(existing) - env["PYTHONPATH"] = os.pathsep.join(paths) - return env - - -def _start_fake_sidecar(config: BenchmarkEpisodeConfig) -> subprocess.Popen[str]: - return subprocess.Popen( - [ - sys.executable, - "-m", - "dimos_fake_runtime_sidecar.server", - "--host", - config.runtime_host, - "--port", - str(config.runtime_port), - "--robot-id", - config.robot_id, - "--dof", - str(config.dof), - ], - cwd=REPO_ROOT, - env=_sidecar_env(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - -def _command_frame(owner: MotorShmOwner, robot_id: str) -> tuple[int, MotorActionFrame]: - sequence, commands = owner.read_commands() - return sequence, MotorActionFrame( - robot_id=robot_id, - names=owner.motor_names, - q=[command.q for command in commands], - dq=[command.dq for command in commands], - kp=[command.kp for command in commands], - kd=[command.kd for command in commands], - tau=[command.tau for command in commands], - sequence=sequence, - ) - - def _trace_summary(trace: list[dict[str, object]]) -> dict[str, object]: final = trace[-1] if trace else {} return { @@ -111,21 +57,18 @@ def _trace_summary(trace: list[dict[str, object]]) -> dict[str, object]: def run_demo(config_path: Path) -> Path: config = _load_config(config_path) - sidecar = _start_fake_sidecar(config) - client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") - owner: MotorShmOwner | None = None - coordinator: ControlCoordinator | None = None - sidecar_output = "" + module = FakeRuntimeModule( + robot_id=config.robot_id, + dof=config.dof, + step_hz=config.control_step_hz, + ) cleanup_status: dict[str, object] = { - "coordinator_stopped": False, - "shm_unlinked": False, - "sidecar_stopped": False, + "runtime_module_stopped": False, } try: - health = client.wait_until_healthy(timeout_s=5.0) - description = client.describe() + description = module.describe() plan = resolve_runtime_plan(config, description) - reset = client.reset( + reset = module.reset( EpisodeResetRequest( episode_id=plan.episode_id, task_id=plan.task_id, @@ -133,72 +76,29 @@ def run_demo(config_path: Path) -> Path: ) ) - owner = MotorShmOwner(plan.shm_key, plan.motor_names) - owner.write_state( - [MotorState(q=0.0) for _ in plan.motor_names], - sequence=0, - ) - - hardware = HardwareComponent( - hardware_id=plan.robot_id, - hardware_type=HardwareType.WHOLE_BODY, - joints=plan.motor_names, - adapter_type="benchmark_runtime", - address=plan.shm_key, - adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, - ) - task_name = f"servo_{plan.robot_id}" - coordinator = ControlCoordinator( - tick_rate=float(plan.control_step_hz), - publish_joint_state=False, - hardware=[hardware], - tasks=[ - TaskConfig( - name=task_name, - type="servo", - joint_names=plan.motor_names, - auto_start=True, - params={"timeout": 0.0, "default_positions": [0.0] * len(plan.motor_names)}, - ) - ], - ) - coordinator.start() - trace: list[dict[str, object]] = [] target = [plan.target_position] * len(plan.motor_names) for tick in range(plan.ticks): - accepted = coordinator.task_invoke( - task_name, "set_target", {"positions": target, "t_now": None} + action = MotorActionFrame( + robot_id=plan.robot_id, + names=plan.motor_names, + q=target, + sequence=tick, ) - if accepted is not True: - raise RuntimeError(f"servo task rejected target at tick {tick}") - time.sleep(1.0 / plan.control_step_hz) - command_sequence, action = _command_frame(owner, plan.robot_id) - response = client.step( + response = module.step( StepRequest(episode_id=plan.episode_id, tick_id=tick, action=action) ) - owner.write_state( - [ - MotorState( - q=response.motor_state.q[i], - dq=response.motor_state.dq[i], - tau=response.motor_state.tau[i], - ) - for i in range(len(plan.motor_names)) - ], - sequence=response.motor_state.sequence, - ) trace.append( { "tick": tick, - "command_sequence": command_sequence, + "command_sequence": action.sequence, "state_sequence": response.motor_state.sequence, "command_q": action.q, "state_q": response.motor_state.q, } ) - score = client.score() + score = module.score() if not score.success: raise RuntimeError(f"fake runtime smoke demo failed score: {score.reason}") @@ -210,35 +110,20 @@ def run_demo(config_path: Path) -> Path: write_json(artifact_dir / "motor_trace.json", trace) write_json(artifact_dir / "protocol_trace_summary.json", _trace_summary(trace)) write_json(artifact_dir / "score.json", score) - write_json(artifact_dir / "health.json", health) + write_json( + artifact_dir / "module_status.json", {"ok": True, "runtime_id": description.runtime_id} + ) return artifact_dir finally: - if coordinator is not None: - try: - coordinator.stop() - cleanup_status["coordinator_stopped"] = True - except Exception as exc: - cleanup_status["coordinator_error"] = str(exc) - if owner is not None: - try: - owner.close() - owner.unlink() - cleanup_status["shm_unlinked"] = True - except Exception as exc: - cleanup_status["shm_error"] = str(exc) - sidecar.terminate() try: - sidecar_output, _ = sidecar.communicate(timeout=2.0) - except subprocess.TimeoutExpired: - sidecar.kill() - sidecar_output, _ = sidecar.communicate(timeout=2.0) - cleanup_status["sidecar_returncode"] = sidecar.returncode - cleanup_status["sidecar_stopped"] = sidecar.returncode is not None + module.stop() + cleanup_status["runtime_module_stopped"] = True + except Exception as exc: + cleanup_status["runtime_module_error"] = str(exc) if config.artifact_dir: - sidecar_log = (REPO_ROOT / config.artifact_dir / "fake_sidecar.log").resolve() - sidecar_log.parent.mkdir(parents=True, exist_ok=True) - sidecar_log.write_text(sidecar_output) - write_json(sidecar_log.parent / "cleanup_status.json", cleanup_status) + artifact_dir = (REPO_ROOT / config.artifact_dir).resolve() + artifact_dir.mkdir(parents=True, exist_ok=True) + write_json(artifact_dir / "cleanup_status.json", cleanup_status) def main() -> None: diff --git a/scripts/benchmarks/demo_libero_pro_runtime.py b/scripts/benchmarks/demo_libero_pro_runtime.py index e899594853..998caf7946 100644 --- a/scripts/benchmarks/demo_libero_pro_runtime.py +++ b/scripts/benchmarks/demo_libero_pro_runtime.py @@ -13,17 +13,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Run a LIBERO-PRO registered-task demo through the DimOS runtime sidecar path. +"""Run a LIBERO-PRO registered-task demo through the DimOS runtime module path. -The DimOS process intentionally does not import LIBERO-PRO, Robosuite, or Torch. -It starts the LIBERO-PRO sidecar in a subprocess and communicates through the -shared runtime protocol plus the local SHM motor bridge. +This script runs from the host DimOS environment and deploys the first-class +``LiberoProRuntimeModule`` into the package-local LIBERO-PRO Python project +runtime. The simulator dependencies stay in the placed worker environment while +the host process drives DimOS-native RPCs and streams. """ from __future__ import annotations import argparse -from io import BytesIO +from collections.abc import Callable import json import os from pathlib import Path @@ -31,6 +32,7 @@ import subprocess import sys import time +from typing import Protocol import numpy as np @@ -43,9 +45,13 @@ from dimos_runtime_protocol import ( EpisodeResetRequest, + EpisodeResetResponse, MotorActionFrame, - ObservationKind, + MotorStateFrame, + RuntimeDescription, + ScoreOutput, StepRequest, + StepResponse, ) from dimos.benchmark.runtime.artifacts import write_json @@ -57,11 +63,37 @@ ) from dimos.control.components import HardwareComponent, HardwareType from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.core.coordination.module_coordinator import ModuleCoordinator from dimos.hardware.whole_body.spec import MotorState -from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient from dimos.simulation.runtime_client.shm_motor import MotorShmOwner +class _Subscribable(Protocol): + def subscribe(self, cb: Callable[..., object]) -> object: ... + + +class _ImageMessage(Protocol): + data: object + frame_id: str + + +class _LiberoProRuntime(Protocol): + color_image: _Subscribable + camera_info: _Subscribable + motor_state: _Subscribable + runtime_event: _Subscribable + + def describe(self) -> RuntimeDescription: ... + + def reset(self, request: EpisodeResetRequest) -> EpisodeResetResponse: ... + + def step(self, request: StepRequest) -> StepResponse: ... + + def score(self) -> ScoreOutput: ... + + def stop(self) -> None: ... + + def _load_config(path: Path) -> BenchmarkEpisodeConfig: return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) @@ -85,45 +117,6 @@ def _repo_path(path: Path) -> Path: return path if path.is_absolute() else REPO_ROOT / path -def _common_sidecar_args( - config: BenchmarkEpisodeConfig, - options: LiberoProBackendOptions, -) -> list[str]: - command = [ - "--host", - config.runtime_host, - "--port", - str(config.runtime_port), - "--robot-id", - config.robot_id, - "--control-freq", - str(config.control_step_hz), - "--benchmark-name", - options.benchmark_name, - "--task-order-index", - str(options.task_order_index), - "--task-index", - str(options.task_index), - "--init-state-index", - str(options.init_state_index), - "--controller", - options.controller, - "--horizon", - str(options.horizon), - "--bddl-root", - str(_repo_path(options.bddl_root)), - "--init-states-root", - str(_repo_path(options.init_states_root)), - "--seed", - str(config.seed) if config.seed is not None else "0", - ] - if config.visualize: - command.append("--visualize") - for camera_name in options.camera_names: - command.extend(["--camera-name", camera_name]) - return command - - def _prepare_assets(config: BenchmarkEpisodeConfig, options: LiberoProBackendOptions) -> None: subprocess.run( [ @@ -146,42 +139,31 @@ def _prepare_assets(config: BenchmarkEpisodeConfig, options: LiberoProBackendOpt ) -def _start_libero_pro_sidecar( +def _create_runtime_module( config: BenchmarkEpisodeConfig, options: LiberoProBackendOptions, -) -> subprocess.Popen[str]: - command = [ - _sidecar_python(), - "-m", - "dimos_libero_pro_sidecar.server", - *_common_sidecar_args(config, options), - ] - return subprocess.Popen( - command, - cwd=Path("/tmp/opencode"), - env=_sidecar_env(visualize=config.visualize), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - -def _wait_sidecar_healthy( - sidecar: subprocess.Popen[str], - client: RuntimeSidecarClient, - timeout_s: float, -) -> object: - deadline = time.monotonic() + timeout_s - last_error = "" - while time.monotonic() < deadline: - if sidecar.poll() is not None: - raise RuntimeError("LIBERO-PRO sidecar exited before becoming healthy") - try: - return client.health() - except Exception as exc: - last_error = str(exc) - time.sleep(0.1) - raise RuntimeError(f"LIBERO-PRO sidecar did not become healthy: {last_error}") +) -> tuple[_LiberoProRuntime, ModuleCoordinator]: + from dimos_libero_pro_sidecar.blueprint import libero_pro_runtime_blueprint + from dimos_libero_pro_sidecar.module import LiberoProRuntimeModule + + blueprint = libero_pro_runtime_blueprint( + bddl_root=_repo_path(options.bddl_root), + init_states_root=_repo_path(options.init_states_root), + benchmark_name=options.benchmark_name, + robot_id=config.robot_id, + task_order_index=options.task_order_index, + task_index=options.task_index, + init_state_index=options.init_state_index, + controller=options.controller, + camera_names=tuple(options.camera_names), + control_freq=config.control_step_hz, + horizon=options.horizon, + seed=config.seed, + allow_asset_bootstrap=False, + visualize=config.visualize, + ).global_config(viewer="none", n_workers=1) + coordinator = ModuleCoordinator.build(blueprint) + return coordinator.get_instance(LiberoProRuntimeModule), coordinator def _command_frame(owner: MotorShmOwner, robot_id: str) -> tuple[int, MotorActionFrame]: @@ -208,8 +190,8 @@ def _lcm_url(port: int) -> str: return f"udpm://239.255.76.67:{port}?ttl=0" -class RerunStreamPublisher: - """Publish LIBERO-PRO runtime camera observations through DimOS streams.""" +class RuntimeRerunBridge: + """Bridge normal DimOS runtime streams into Rerun.""" def __init__( self, @@ -218,20 +200,11 @@ def __init__( lcm_port: int, memory_limit: str, max_hz: float, - topic_prefix: str = "/libero_pro_runtime", ) -> None: - from dimos.core.transport import LCMTransport - from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo - from dimos.msgs.sensor_msgs.Image import Image - from dimos.protocol.pubsub.impl.lcmpubsub import LCM from dimos.visualization.rerun.bridge import RerunBridgeModule - prefix = topic_prefix.rstrip("/") - self._image_topic = f"{prefix}/color_image" - self._camera_info_topic = f"{prefix}/camera_info" - self._image_entity = "world/libero_pro_runtime/color_image" - self._camera_info_entity = "world/libero_pro_runtime/camera_info" - self._camera_info_published = False + self._image_entity = "world/color_image" + self._camera_info_entity = "world/camera_info" def runtime_camera_blueprint() -> object: import rerun.blueprint as rrb @@ -242,29 +215,12 @@ def runtime_camera_blueprint() -> object: ) ) - def topic_to_entity(topic: object) -> str: - topic_name = getattr(topic, "topic", None) - if not isinstance(topic_name, str): - topic_name = getattr(topic, "name", None) - if not isinstance(topic_name, str): - topic_name = str(topic).split("#")[0] - if topic_name == self._image_topic: - return self._image_entity - if topic_name == self._camera_info_topic: - return self._camera_info_entity - return f"world{topic_name}" - max_hz_by_entity = {self._image_entity: max_hz} if max_hz > 0.0 else {} - lcm_url = _lcm_url(lcm_port) - self._image_transport = LCMTransport(self._image_topic, Image, url=lcm_url) - self._camera_info_transport = LCMTransport(self._camera_info_topic, CameraInfo, url=lcm_url) self._bridge = RerunBridgeModule( - pubsubs=[LCM(url=lcm_url)], blueprint=runtime_camera_blueprint, connect_url=f"rerun+http://127.0.0.1:{grpc_port}/proxy", memory_limit=memory_limit, max_hz=max_hz_by_entity, - topic_to_entity=topic_to_entity, visual_override={ self._camera_info_entity: lambda camera_info: camera_info.to_rerun( image_topic=self._image_entity @@ -276,30 +232,8 @@ def start(self) -> None: self._bridge.start() def stop(self) -> None: - self._image_transport.stop() - self._camera_info_transport.stop() self._bridge.stop() - def publish_rgb(self, rgb: object, *, fov_y_deg: float, frame_id: str) -> None: - from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo - from dimos.msgs.sensor_msgs.Image import Image, ImageFormat - - array = np.asarray(rgb) - if array.ndim != 3 or array.shape[2] < 3: - raise ValueError(f"expected HxWx3 RGB image, got shape {array.shape}") - image = Image.from_numpy(array[:, :, :3], format=ImageFormat.RGB, frame_id="") - camera_info = CameraInfo.from_fov( - fov_y_deg, - width=image.width, - height=image.height, - axis="vertical", - frame_id=frame_id, - ).with_ts(image.ts) - self._image_transport.broadcast(None, image) - if not self._camera_info_published: - self._camera_info_transport.broadcast(None, camera_info) - self._camera_info_published = True - def _target_for_tick(plan_target: float, motor_count: int, tick: int) -> list[float]: phase = 1.0 if (tick // 50) % 2 == 0 else -1.0 @@ -310,8 +244,8 @@ def _target_for_tick(plan_target: float, motor_count: int, tick: int) -> list[fl return targets[:motor_count] -def _safe_payload_name(data_ref: str) -> str: - return data_ref.strip("/").replace("/", "_") or "payload" +def _safe_stream_name(stream_name: str) -> str: + return stream_name.strip("/").replace("/", "_") or "camera" def _write_rgb_jpeg(path: Path, rgb: object) -> None: @@ -326,75 +260,58 @@ def _write_rgb_jpeg(path: Path, rgb: object) -> None: raise RuntimeError(f"failed to write JPEG {path}") -def _publish_rerun_observations( - client: RuntimeSidecarClient, - response_observations: object, - publisher: RerunStreamPublisher | None, - *, - image_dump_dir: Path | None = None, - image_dump_label: str = "frame", -) -> int: - if publisher is None and image_dump_dir is None: - return 0 - if not isinstance(response_observations, list): - return 0 - published = 0 - for frame in response_observations: - if getattr(frame, "kind", None) != ObservationKind.IMAGE: - continue - data_ref = getattr(frame, "data_ref", None) - if not isinstance(data_ref, str): - continue - payload = client.payload(data_ref) - image = np.load(BytesIO(payload), allow_pickle=False) - metadata = getattr(frame, "metadata", {}) - fov_y_deg = 45.0 - display_image = image - if isinstance(metadata, dict): - maybe_fov = metadata.get("fov_y_deg") - if isinstance(maybe_fov, int | float): - fov_y_deg = float(maybe_fov) - if metadata.get("image_convention") == "opengl": - display_image = np.flipud(image) - stream = getattr(frame, "stream", "camera") - frame_id = stream if isinstance(stream, str) else "camera" - if image_dump_dir is not None: - _write_rgb_jpeg(image_dump_dir / f"{image_dump_label}_{frame_id}_raw.jpg", image) - _write_rgb_jpeg( - image_dump_dir / f"{image_dump_label}_{frame_id}_display.jpg", - display_image, - ) - if publisher is not None: - publisher.publish_rgb(display_image, fov_y_deg=fov_y_deg, frame_id=frame_id) - published += 1 - return published +class RuntimeStreamCapture: + """Collect DimOS-native outputs published by the local runtime module.""" + + def __init__(self) -> None: + self.images: list[_ImageMessage] = [] + self.camera_infos: list[object] = [] + self.motor_states: list[MotorStateFrame] = [] + self.runtime_events: list[object] = [] + + def attach(self, module: _LiberoProRuntime) -> None: + module.color_image.subscribe(self.images.append) + module.camera_info.subscribe(self.camera_infos.append) + module.motor_state.subscribe(self.motor_states.append) + module.runtime_event.subscribe(self.runtime_events.append) + + def take_images(self) -> list[_ImageMessage]: + images = self.images + self.images = [] + return images + + def take_camera_infos(self) -> list[object]: + camera_infos = self.camera_infos + self.camera_infos = [] + return camera_infos + + def take_motor_states(self) -> list[MotorStateFrame]: + motor_states = self.motor_states + self.motor_states = [] + return motor_states + def take_runtime_events(self) -> list[object]: + runtime_events = self.runtime_events + self.runtime_events = [] + return runtime_events -def _fetch_camera_payloads( - client: RuntimeSidecarClient, - observations: object, - payload_dir: Path, + +def _camera_image_records( + images: list[_ImageMessage], + image_dir: Path, label: str, ) -> list[dict[str, object]]: - if not isinstance(observations, list): - return [] records: list[dict[str, object]] = [] - for frame in observations: - if getattr(frame, "kind", None) != ObservationKind.IMAGE: - continue - data_ref = getattr(frame, "data_ref", None) - if not isinstance(data_ref, str): - continue - payload = client.payload(data_ref) - array = np.load(BytesIO(payload), allow_pickle=False) - payload_path = payload_dir / f"{label}_{_safe_payload_name(data_ref)}.npy" - payload_path.parent.mkdir(parents=True, exist_ok=True) - payload_path.write_bytes(payload) + for index, image in enumerate(images): + array = np.asarray(image.data) + frame_id = image.frame_id or "camera" + image_path = image_dir / f"{label}_{_safe_stream_name(str(frame_id))}_{index:02d}.npy" + image_path.parent.mkdir(parents=True, exist_ok=True) + np.save(image_path, array, allow_pickle=False) records.append( { - "stream": getattr(frame, "stream", "camera"), - "data_ref": data_ref, - "path": str(payload_path), + "stream": frame_id, + "path": str(image_path), "shape": list(array.shape), "dtype": str(array.dtype), "min": float(array.min()) if array.size else 0.0, @@ -404,17 +321,42 @@ def _fetch_camera_payloads( return records +def _dump_stream_images( + images: list[_ImageMessage], + *, + image_dump_dir: Path | None = None, + image_dump_label: str = "frame", +) -> int: + if image_dump_dir is None: + return 0 + published = 0 + for index, image_msg in enumerate(images): + image = np.asarray(image_msg.data) + display_image = image + frame_id = image_msg.frame_id or "camera" + if image_dump_dir is not None: + _write_rgb_jpeg( + image_dump_dir / f"{image_dump_label}_{frame_id}_{index:02d}_raw.jpg", image + ) + _write_rgb_jpeg( + image_dump_dir / f"{image_dump_label}_{frame_id}_{index:02d}_display.jpg", + display_image, + ) + published += 1 + return published + + def _trace_summary(trace: list[dict[str, object]]) -> dict[str, object]: final = trace[-1] if trace else {} stream_set: set[str] = set() - payload_count = 0 + image_count = 0 for entry in trace: value = entry.get("observation_streams", []) if isinstance(value, list): stream_set.update(stream for stream in value if isinstance(stream, str)) - payloads = entry.get("camera_payloads", []) - if isinstance(payloads, list): - payload_count += len(payloads) + images = entry.get("camera_images", []) + if isinstance(images, list): + image_count += len(images) return { "ticks": len(trace), "first_command_sequence": trace[0].get("command_sequence") if trace else None, @@ -423,7 +365,7 @@ def _trace_summary(trace: list[dict[str, object]]) -> dict[str, object]: "final_command_q": final.get("command_q"), "final_state_q": final.get("state_q"), "observation_streams": sorted(stream_set), - "camera_payload_count": payload_count, + "camera_image_count": image_count, "final_reward": final.get("reward"), "final_done": final.get("done"), "final_success": final.get("success"), @@ -445,33 +387,34 @@ def run_demo_config( if prepare_assets: _prepare_assets(config, options) artifact_dir = (REPO_ROOT / config.artifact_dir).resolve() - payload_dir = artifact_dir / "camera_payloads" + image_dir = artifact_dir / "camera_images" client_image_dump_dir = artifact_dir / "images" / "client" - sidecar = _start_libero_pro_sidecar(config, options) - client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") + runtime, module_coordinator = _create_runtime_module(config, options) + stream_capture = RuntimeStreamCapture() + stream_capture.attach(runtime) owner: MotorShmOwner | None = None coordinator: ControlCoordinator | None = None - rerun_publisher: RerunStreamPublisher | None = None + rerun_bridge: RuntimeRerunBridge | None = None selected_rerun_grpc_port: int | None = None selected_rerun_lcm_port: int | None = None published_rerun_frames = 0 - sidecar_output = "" cleanup_status: dict[str, object] = { "coordinator_stopped": False, + "module_coordinator_stopped": False, "shm_unlinked": False, - "sidecar_stopped": False, + "runtime_stopped": False, } try: try: - health = _wait_sidecar_healthy(sidecar, client, timeout_s=30.0) + description = runtime.describe() except RuntimeError as exc: raise RuntimeError( - "LIBERO-PRO sidecar did not become healthy. Run this demo from an " - "environment that can import LIBERO-PRO and has prepared BDDL/init assets." + "LIBERO-PRO runtime module could not start. Prepare the " + "packages/dimos-libero-pro-sidecar runtime environment and BDDL/init " + "assets first (or pass --prepare-assets for explicit asset bootstrap)." ) from exc - description = client.describe() plan = resolve_runtime_plan(config, description) - reset = client.reset( + reset = runtime.reset( EpisodeResetRequest( episode_id=plan.episode_id, task_id=plan.task_id, @@ -510,21 +453,22 @@ def run_demo_config( if rerun: selected_rerun_grpc_port = rerun_grpc_port if rerun_grpc_port > 0 else _free_tcp_port() selected_rerun_lcm_port = rerun_lcm_port if rerun_lcm_port > 0 else _free_tcp_port() - rerun_publisher = RerunStreamPublisher( + rerun_bridge = RuntimeRerunBridge( grpc_port=selected_rerun_grpc_port, lcm_port=selected_rerun_lcm_port, memory_limit=rerun_memory_limit, max_hz=rerun_max_hz, ) - rerun_publisher.start() + rerun_bridge.start() trace: list[dict[str, object]] = [] - reset_payloads = _fetch_camera_payloads(client, reset.observations, payload_dir, "reset") + reset_images = stream_capture.take_images() + reset_camera_infos = stream_capture.take_camera_infos() + reset_events = stream_capture.take_runtime_events() + reset_image_records = _camera_image_records(reset_images, image_dir, "reset") if rerun and camera_jpeg_dump_every > 0: - _publish_rerun_observations( - client, - reset.observations, - None, + _dump_stream_images( + reset_images, image_dump_dir=client_image_dump_dir, image_dump_label="reset", ) @@ -537,42 +481,49 @@ def run_demo_config( raise RuntimeError(f"servo task rejected target at tick {tick}") time.sleep(1.0 / plan.control_step_hz) command_sequence, action = _command_frame(owner, plan.robot_id) - response = client.step( + response = runtime.step( StepRequest(episode_id=plan.episode_id, tick_id=tick, action=action) ) - camera_payloads = _fetch_camera_payloads( - client, response.observations, payload_dir, f"tick_{tick:06d}" - ) + tick_images = stream_capture.take_images() + tick_camera_infos = stream_capture.take_camera_infos() + tick_motor_states = stream_capture.take_motor_states() + tick_runtime_events = stream_capture.take_runtime_events() + camera_images = _camera_image_records(tick_images, image_dir, f"tick_{tick:06d}") should_dump_jpeg = ( rerun and camera_jpeg_dump_every > 0 and tick % camera_jpeg_dump_every == 0 ) - published_rerun_frames += _publish_rerun_observations( - client, - response.observations, - rerun_publisher, + if rerun: + published_rerun_frames += len(tick_images) + _dump_stream_images( + tick_images, image_dump_dir=client_image_dump_dir if should_dump_jpeg else None, image_dump_label=f"tick_{tick:06d}", ) + stream_motor_state = ( + tick_motor_states[-1] if tick_motor_states else response.motor_state + ) owner.write_state( [ MotorState( - q=response.motor_state.q[i], - dq=response.motor_state.dq[i], - tau=response.motor_state.tau[i], + q=stream_motor_state.q[i], + dq=stream_motor_state.dq[i], + tau=stream_motor_state.tau[i], ) for i in range(len(plan.motor_names)) ], - sequence=response.motor_state.sequence, + sequence=stream_motor_state.sequence, ) trace.append( { "tick": tick, "command_sequence": command_sequence, - "state_sequence": response.motor_state.sequence, + "state_sequence": stream_motor_state.sequence, "command_q": action.q, - "state_q": response.motor_state.q, - "observation_streams": [frame.stream for frame in response.observations], - "camera_payloads": camera_payloads, + "state_q": stream_motor_state.q, + "observation_streams": [str(record["stream"]) for record in camera_images], + "camera_images": camera_images, + "camera_info_count": len(tick_camera_infos), + "runtime_event_count": len(tick_runtime_events), "rerun_frames_published": published_rerun_frames, "reward": response.reward, "done": response.done, @@ -586,12 +537,20 @@ def run_demo_config( print("visual demo complete; keeping LIBERO-PRO viewer open for 5 seconds") time.sleep(5.0) - score = client.score() + score = runtime.score() write_json(artifact_dir / "episode_config.json", config) write_json(artifact_dir / "runtime_description.json", description) write_json(artifact_dir / "resolved_runtime_plan.json", plan) write_json(artifact_dir / "reset_response.json", reset) - write_json(artifact_dir / "reset_camera_payloads.json", reset_payloads) + write_json( + artifact_dir / "reset_runtime_streams.json", + { + "image_count": len(reset_images), + "camera_info_count": len(reset_camera_infos), + "runtime_event_count": len(reset_events), + }, + ) + write_json(artifact_dir / "reset_camera_images.json", reset_image_records) write_json(artifact_dir / "motor_trace.json", trace) write_json(artifact_dir / "protocol_trace_summary.json", _trace_summary(trace)) write_json( @@ -610,12 +569,11 @@ def run_demo_config( }, ) write_json(artifact_dir / "score.json", score) - write_json(artifact_dir / "health.json", health) return artifact_dir finally: - if rerun_publisher is not None: + if rerun_bridge is not None: try: - rerun_publisher.stop() + rerun_bridge.stop() cleanup_status["rerun_stopped"] = True except Exception as exc: cleanup_status["rerun_error"] = str(exc) @@ -632,18 +590,14 @@ def run_demo_config( cleanup_status["shm_unlinked"] = True except Exception as exc: cleanup_status["shm_error"] = str(exc) - sidecar.terminate() try: - sidecar_output, _ = sidecar.communicate(timeout=2.0) - except subprocess.TimeoutExpired: - sidecar.kill() - sidecar_output, _ = sidecar.communicate(timeout=2.0) - cleanup_status["sidecar_returncode"] = sidecar.returncode - cleanup_status["sidecar_stopped"] = sidecar.returncode is not None - sidecar_log = artifact_dir / "libero_pro_sidecar.log" - sidecar_log.parent.mkdir(parents=True, exist_ok=True) - sidecar_log.write_text(sidecar_output) - write_json(sidecar_log.parent / "cleanup_status.json", cleanup_status) + module_coordinator.stop() + cleanup_status["module_coordinator_stopped"] = True + cleanup_status["runtime_stopped"] = True + except Exception as exc: + cleanup_status["runtime_error"] = str(exc) + artifact_dir.mkdir(parents=True, exist_ok=True) + write_json(artifact_dir / "cleanup_status.json", cleanup_status) def run_demo(config_path: Path) -> Path: @@ -665,7 +619,7 @@ def main() -> None: parser.add_argument( "--prepare-assets", action="store_true", - help="Run the explicit LIBERO-PRO asset bootstrap before sidecar startup.", + help="Run the explicit LIBERO-PRO asset bootstrap before runtime module startup.", ) parser.add_argument( "--visual", @@ -694,7 +648,7 @@ def main() -> None: parser.add_argument( "--rerun", action="store_true", - help="Publish LIBERO-PRO camera payloads to Rerun through DimOS streams.", + help="Publish LIBERO-PRO camera stream outputs to Rerun through DimOS streams.", ) parser.add_argument( "--rerun-memory-limit", @@ -723,7 +677,7 @@ def main() -> None: "--camera-jpeg-dump-every", type=int, default=25, - help="When --rerun is enabled, dump every Nth camera payload as JPEGs. Use <=0 to disable.", + help="When --rerun is enabled, dump every Nth camera stream output as JPEGs. Use <=0 to disable.", ) args = parser.parse_args() try: diff --git a/scripts/benchmarks/demo_rerun_color_smoke.py b/scripts/benchmarks/demo_rerun_color_smoke.py index d115751455..af5567acbd 100644 --- a/scripts/benchmarks/demo_rerun_color_smoke.py +++ b/scripts/benchmarks/demo_rerun_color_smoke.py @@ -41,9 +41,10 @@ sys.path.insert(0, str(REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src")) from dimos.benchmark.runtime.artifacts import write_json +from dimos.core.transport import LCMTransport from dimos.msgs.sensor_msgs.Image import Image, ImageFormat from scripts.benchmarks.demo_robosuite_panda_lift import ( - RerunStreamPublisher, + RuntimeRerunBridge, _free_tcp_port, ) @@ -109,25 +110,31 @@ def main() -> None: ) grpc_port = args.rerun_grpc_port if args.rerun_grpc_port > 0 else _free_tcp_port() - lcm_port = args.rerun_lcm_port if args.rerun_lcm_port > 0 else _free_tcp_port() - publisher = RerunStreamPublisher( + _ = args.rerun_lcm_port if args.rerun_lcm_port > 0 else _free_tcp_port() + bridge = RuntimeRerunBridge( grpc_port=grpc_port, - lcm_port=lcm_port, + lcm_port=0, memory_limit=args.rerun_memory_limit, max_hz=args.hz, - topic_prefix="/rerun_color_smoke", ) + transport = LCMTransport("/color_image", Image) published = 0 try: - publisher.start() + bridge.start() period_s = 1.0 / args.hz if args.hz > 0.0 else 0.0 for _ in range(args.frames): - publisher.publish_rgb(lcm_decoded, fov_y_deg=45.0, frame_id="color_smoke_camera") + transport.broadcast( + None, + Image.from_numpy( + lcm_decoded, format=ImageFormat.RGB, frame_id="color_smoke_camera" + ), + ) published += 1 if period_s > 0.0: time.sleep(period_s) finally: - publisher.stop() + transport.stop() + bridge.stop() summary = { "ok": True, @@ -138,7 +145,7 @@ def main() -> None: "height": args.height, "width": args.width, "rerun_grpc_port": grpc_port, - "rerun_lcm_port": lcm_port, + "rerun_lcm_port": None, "rerun_memory_limit": args.rerun_memory_limit, "source": _pixel_summary(source), "npy_decoded": _pixel_summary(npy_decoded), diff --git a/scripts/benchmarks/demo_robosuite_camera_payload_smoke.py b/scripts/benchmarks/demo_robosuite_camera_payload_smoke.py deleted file mode 100644 index bae8cbfe83..0000000000 --- a/scripts/benchmarks/demo_robosuite_camera_payload_smoke.py +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Smoke-test Robosuite camera payload transmission without Rerun. - -This verifies the suspect seam directly: - -Robosuite observation image in sidecar -> `.npy` payload store -> HTTP fetch -> -client `np.load` decode. - -The sidecar records source image hashes/statistics in the `ObservationFrame` -metadata before storing the payload. This script fetches the payload and verifies -the decoded array matches those metadata exactly. -""" - -from __future__ import annotations - -import argparse -import hashlib -from io import BytesIO -import json -import os -from pathlib import Path -import subprocess -import sys -import time - -import numpy as np - -REPO_ROOT = Path(__file__).resolve().parents[2] -PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" -ROBOSUITE_SIDECAR_SRC = REPO_ROOT / "packages" / "dimos-robosuite-sidecar" / "src" - -for package_src in (PROTOCOL_SRC, ROBOSUITE_SIDECAR_SRC): - sys.path.insert(0, str(package_src)) - -from dimos_runtime_protocol import ( - EpisodeResetRequest, - MotorActionFrame, - ObservationKind, - StepRequest, -) - -from dimos.benchmark.runtime.artifacts import write_json -from dimos.benchmark.runtime.config import ( - BenchmarkEpisodeConfig, - resolve_runtime_plan, -) -from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient - - -def _load_config(path: Path) -> BenchmarkEpisodeConfig: - return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) - - -def _sidecar_env() -> dict[str, str]: - env = dict(os.environ) - existing = env.get("PYTHONPATH", "") - paths = [str(PROTOCOL_SRC), str(ROBOSUITE_SIDECAR_SRC)] - if existing: - paths.append(existing) - env["PYTHONPATH"] = os.pathsep.join(paths) - return env - - -def _start_sidecar(config: BenchmarkEpisodeConfig) -> subprocess.Popen[str]: - command = [ - sys.executable, - "-m", - "dimos_robosuite_sidecar.server", - "--host", - config.runtime_host, - "--port", - str(config.runtime_port), - "--env-name", - config.env_name, - "--robot-id", - config.robot_id, - "--robot-model", - config.robot_model, - "--controller", - config.controller, - "--control-freq", - str(config.control_step_hz), - "--horizon", - str(config.horizon), - "--camera-name", - config.camera_name, - "--seed", - str(config.seed) if config.seed is not None else "0", - ] - return subprocess.Popen( - command, - cwd=REPO_ROOT, - env=_sidecar_env(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - -def _wait_healthy(sidecar: subprocess.Popen[str], client: RuntimeSidecarClient) -> object: - deadline = time.monotonic() + 20.0 - last_error = "" - while time.monotonic() < deadline: - if sidecar.poll() is not None: - raise RuntimeError("Robosuite sidecar exited before becoming healthy") - try: - return client.health() - except Exception as exc: - last_error = str(exc) - time.sleep(0.1) - raise RuntimeError(f"Robosuite sidecar did not become healthy: {last_error}") - - -def _array_sha256(array: np.ndarray) -> str: - return hashlib.sha256(np.ascontiguousarray(array).tobytes()).hexdigest() - - -def _payload_sha256(payload: bytes) -> str: - return hashlib.sha256(payload).hexdigest() - - -def _pixel_summary(array: np.ndarray) -> dict[str, object]: - return { - "shape": [int(item) for item in array.shape], - "dtype": str(array.dtype), - "min": float(array.min()) if array.size else 0.0, - "max": float(array.max()) if array.size else 0.0, - "mean": float(array.mean()) if array.size else 0.0, - "top_left": [int(item) for item in array[0, 0].tolist()] if array.ndim == 3 else [], - "center": [int(item) for item in array[array.shape[0] // 2, array.shape[1] // 2].tolist()] - if array.ndim == 3 - else [], - "bottom_left": [int(item) for item in array[-1, 0].tolist()] if array.ndim == 3 else [], - } - - -def _write_jpeg(path: Path, rgb: np.ndarray) -> None: - import cv2 - - path.parent.mkdir(parents=True, exist_ok=True) - if rgb.ndim != 3 or rgb.shape[2] < 3: - raise ValueError(f"expected HxWx3 RGB image, got shape {rgb.shape}") - bgr = cv2.cvtColor(rgb[:, :, :3], cv2.COLOR_RGB2BGR) - if not cv2.imwrite(str(path), bgr): - raise RuntimeError(f"failed to write JPEG {path}") - - -def _validate_image_payload( - client: RuntimeSidecarClient, - frame: object, - *, - phase: str, - image_dir: Path, -) -> dict[str, object]: - data_ref = getattr(frame, "data_ref", None) - if not isinstance(data_ref, str): - raise AssertionError("image frame did not include data_ref") - metadata = getattr(frame, "metadata", {}) - if not isinstance(metadata, dict): - raise AssertionError("image frame metadata is not a dict") - payload = client.payload(data_ref) - payload_second_fetch = client.payload(data_ref) - if payload != payload_second_fetch: - raise AssertionError("re-fetching the same payload returned different bytes") - decoded = np.load(BytesIO(payload), allow_pickle=False) - decoded_second = np.load(BytesIO(payload_second_fetch), allow_pickle=False) - if not np.array_equal(decoded, decoded_second): - raise AssertionError("re-fetching the same payload decoded to different arrays") - - expected_shape = getattr(frame, "shape", None) - expected_dtype = getattr(frame, "dtype", None) - if expected_shape != [int(item) for item in decoded.shape]: - raise AssertionError(f"shape mismatch: metadata={expected_shape} decoded={decoded.shape}") - if expected_dtype != str(decoded.dtype): - raise AssertionError(f"dtype mismatch: metadata={expected_dtype} decoded={decoded.dtype}") - if metadata.get("array_sha256") != _array_sha256(decoded): - raise AssertionError("decoded array hash does not match sidecar source hash") - if metadata.get("payload_sha256") != _payload_sha256(payload): - raise AssertionError("payload hash does not match sidecar source payload hash") - - image_basename = f"{phase}_{getattr(frame, 'stream', 'camera')}" - raw_jpeg = image_dir / f"{image_basename}_raw.jpg" - _write_jpeg(raw_jpeg, decoded) - display_jpeg: Path | None = None - if metadata.get("image_convention") == "opengl": - display_jpeg = image_dir / f"{image_basename}_display_flipud.jpg" - _write_jpeg(display_jpeg, np.flipud(decoded)) - - return { - "stream": getattr(frame, "stream", ""), - "data_ref": data_ref, - "encoding": getattr(frame, "encoding", ""), - "same_payload_refetch_matches": True, - "raw_jpeg": str(raw_jpeg), - "display_jpeg": str(display_jpeg) if display_jpeg is not None else None, - "metadata": metadata, - "decoded": _pixel_summary(decoded), - } - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--config", - type=Path, - default=REPO_ROOT - / "dimos" - / "benchmark" - / "runtime" - / "configs" - / "robosuite_panda_lift.json", - ) - parser.add_argument("--ticks", type=int, default=3) - parser.add_argument("--camera-name", default=None) - parser.add_argument( - "--artifact-dir", - type=Path, - default=REPO_ROOT / "artifacts" / "benchmark" / "robosuite-camera-payload-smoke", - ) - args = parser.parse_args() - - config = _load_config(args.config) - updates: dict[str, object] = { - "ticks": args.ticks, - "horizon": max(config.horizon, args.ticks + 1), - } - if args.camera_name is not None: - updates["camera_name"] = args.camera_name - config = BenchmarkEpisodeConfig.model_validate({**config.model_dump(), **updates}) - - sidecar = _start_sidecar(config) - client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") - sidecar_output = "" - checks: list[dict[str, object]] = [] - image_dir = args.artifact_dir / "images" - try: - health = _wait_healthy(sidecar, client) - description = client.describe() - plan = resolve_runtime_plan(config, description) - reset = client.reset( - EpisodeResetRequest(episode_id=plan.episode_id, task_id=plan.task_id, seed=config.seed) - ) - - for frame in reset.observations: - if getattr(frame, "kind", None) == ObservationKind.IMAGE: - phase = "reset" - checks.append( - { - "phase": phase, - **_validate_image_payload(client, frame, phase=phase, image_dir=image_dir), - } - ) - - zero_action = MotorActionFrame( - robot_id=plan.robot_id, names=plan.motor_names, q=[0.0] * len(plan.motor_names) - ) - for tick in range(plan.ticks): - response = client.step( - StepRequest(episode_id=plan.episode_id, tick_id=tick, action=zero_action) - ) - for frame in response.observations: - if getattr(frame, "kind", None) == ObservationKind.IMAGE: - phase = f"step_{tick}" - checks.append( - { - "phase": phase, - **_validate_image_payload( - client, frame, phase=phase, image_dir=image_dir - ), - } - ) - - summary = { - "ok": True, - "camera_name": config.camera_name, - "ticks": plan.ticks, - "checked_payloads": len(checks), - "image_dir": str(image_dir), - "checks": checks, - "health": getattr(health, "model_dump", lambda **_: health)(mode="json"), - "runtime_description": description.model_dump(mode="json"), - } - write_json(args.artifact_dir / "camera_payload_smoke_summary.json", summary) - print( - json.dumps( - { - "ok": True, - "artifact_dir": str(args.artifact_dir), - "checked_payloads": len(checks), - }, - indent=2, - ) - ) - finally: - sidecar.terminate() - try: - sidecar_output, _ = sidecar.communicate(timeout=2.0) - except subprocess.TimeoutExpired: - sidecar.kill() - sidecar_output, _ = sidecar.communicate(timeout=2.0) - args.artifact_dir.mkdir(parents=True, exist_ok=True) - (args.artifact_dir / "robosuite_sidecar.log").write_text(sidecar_output) - - -if __name__ == "__main__": - main() diff --git a/scripts/benchmarks/demo_robosuite_panda_lift.py b/scripts/benchmarks/demo_robosuite_panda_lift.py index 053b6c0180..e063a9b776 100644 --- a/scripts/benchmarks/demo_robosuite_panda_lift.py +++ b/scripts/benchmarks/demo_robosuite_panda_lift.py @@ -15,22 +15,24 @@ """Run the Robosuite Panda Lift plumbing demo with a real ControlCoordinator. -This script intentionally does not install or import Robosuite in the DimOS -process. It starts the Robosuite sidecar package in a subprocess and communicates -through the shared runtime protocol plus the local SHM motor bridge. +By default this demo runs from the host DimOS environment and deploys +``RobosuiteRuntimeModule`` into the package-local Robosuite Python project runtime +via the standard blueprint placement API. Robosuite dependencies stay in the +placed worker environment; the host process owns orchestration, artifacts, and +Rerun stream forwarding. + +Use ``--runtime-mode local`` only as a debug fallback when intentionally running +the whole demo inside the Robosuite-capable package environment. """ from __future__ import annotations import argparse -from io import BytesIO import json -import os from pathlib import Path -import socket -import subprocess import sys import time +from typing import cast REPO_ROOT = Path(__file__).resolve().parents[2] PROTOCOL_SRC = REPO_ROOT / "packages" / "dimos-runtime-protocol" / "src" @@ -42,7 +44,7 @@ from dimos_runtime_protocol import ( EpisodeResetRequest, MotorActionFrame, - ObservationKind, + MotorStateFrame, StepRequest, ) @@ -54,7 +56,6 @@ from dimos.control.components import HardwareComponent, HardwareType from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.hardware.whole_body.spec import MotorState -from dimos.simulation.runtime_client.http_client import RuntimeSidecarClient from dimos.simulation.runtime_client.shm_motor import MotorShmOwner @@ -62,86 +63,6 @@ def _load_config(path: Path) -> BenchmarkEpisodeConfig: return BenchmarkEpisodeConfig.model_validate_json(path.read_text()) -def _sidecar_env() -> dict[str, str]: - env = dict(os.environ) - existing = env.get("PYTHONPATH", "") - paths = [str(PROTOCOL_SRC), str(ROBOSUITE_SIDECAR_SRC)] - if existing: - paths.append(existing) - env["PYTHONPATH"] = os.pathsep.join(paths) - return env - - -def _start_robosuite_sidecar( - config: BenchmarkEpisodeConfig, - *, - server_image_dump_dir: Path | None = None, - image_dump_every: int = 0, -) -> subprocess.Popen[str]: - command = [ - sys.executable, - "-m", - "dimos_robosuite_sidecar.server", - "--host", - config.runtime_host, - "--port", - str(config.runtime_port), - "--env-name", - config.env_name, - "--robot-id", - config.robot_id, - "--robot-model", - config.robot_model, - "--controller", - config.controller, - "--control-freq", - str(config.control_step_hz), - "--horizon", - str(config.horizon), - "--camera-name", - config.camera_name, - "--seed", - str(config.seed) if config.seed is not None else "0", - ] - if config.visualize: - command.append("--visualize") - if server_image_dump_dir is not None and image_dump_every > 0: - command.extend( - [ - "--image-dump-dir", - str(server_image_dump_dir), - "--image-dump-every", - str(image_dump_every), - ] - ) - return subprocess.Popen( - command, - cwd=REPO_ROOT, - env=_sidecar_env(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - -def _wait_sidecar_healthy( - sidecar: subprocess.Popen[str], - client: RuntimeSidecarClient, - timeout_s: float, -) -> object: - deadline = time.monotonic() + timeout_s - last_error = "" - while time.monotonic() < deadline: - if sidecar.poll() is not None: - raise RuntimeError("Robosuite sidecar exited before becoming healthy") - try: - return client.health() - except Exception as exc: - last_error = str(exc) - time.sleep(0.1) - raise RuntimeError(f"Robosuite sidecar did not become healthy: {last_error}") - - def _command_frame(owner: MotorShmOwner, robot_id: str) -> tuple[int, MotorActionFrame]: sequence, commands = owner.read_commands() return sequence, MotorActionFrame( @@ -157,6 +78,8 @@ def _command_frame(owner: MotorShmOwner, robot_id: str) -> tuple[int, MotorActio def _free_tcp_port() -> int: + import socket + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) @@ -166,8 +89,8 @@ def _lcm_url(port: int) -> str: return f"udpm://239.255.76.67:{port}?ttl=0" -class RerunStreamPublisher: - """Publish runtime camera observations through DimOS streams for Rerun.""" +class RuntimeRerunBridge: + """Bridge normal DimOS runtime streams into Rerun.""" def __init__( self, @@ -176,20 +99,11 @@ def __init__( lcm_port: int, memory_limit: str, max_hz: float, - topic_prefix: str = "/robosuite_runtime", ) -> None: - from dimos.core.transport import LCMTransport - from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo - from dimos.msgs.sensor_msgs.Image import Image - from dimos.protocol.pubsub.impl.lcmpubsub import LCM from dimos.visualization.rerun.bridge import RerunBridgeModule - prefix = topic_prefix.rstrip("/") - self._image_topic = f"{prefix}/color_image" - self._camera_info_topic = f"{prefix}/camera_info" - self._image_entity = "world/robosuite_runtime/color_image" - self._camera_info_entity = "world/robosuite_runtime/camera_info" - self._camera_info_published = False + self._image_entity = "world/color_image" + self._camera_info_entity = "world/camera_info" def runtime_camera_blueprint() -> object: import rerun.blueprint as rrb @@ -200,122 +114,81 @@ def runtime_camera_blueprint() -> object: ) ) - def topic_to_entity(topic: object) -> str: - topic_name = getattr(topic, "topic", None) - if not isinstance(topic_name, str): - topic_name = getattr(topic, "name", None) - if not isinstance(topic_name, str): - topic_name = str(topic).split("#")[0] - if topic_name == self._image_topic: - return self._image_entity - if topic_name == self._camera_info_topic: - return self._camera_info_entity - return f"world{topic_name}" - max_hz_by_entity = {self._image_entity: max_hz} if max_hz > 0.0 else {} - lcm_url = _lcm_url(lcm_port) - - self._image_transport = LCMTransport(self._image_topic, Image, url=lcm_url) - self._camera_info_transport = LCMTransport(self._camera_info_topic, CameraInfo, url=lcm_url) self._bridge = RerunBridgeModule( - pubsubs=[LCM(url=lcm_url)], blueprint=runtime_camera_blueprint, connect_url=f"rerun+http://127.0.0.1:{grpc_port}/proxy", memory_limit=memory_limit, max_hz=max_hz_by_entity, - topic_to_entity=topic_to_entity, - visual_override={ - self._camera_info_entity: lambda camera_info: camera_info.to_rerun( - image_topic=self._image_entity - ), - }, + visual_override={self._camera_info_entity: None}, ) def start(self) -> None: self._bridge.start() def stop(self) -> None: - self._image_transport.stop() - self._camera_info_transport.stop() self._bridge.stop() - def publish_rgb(self, rgb: object, *, fov_y_deg: float, frame_id: str) -> None: - import numpy as np - from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo - from dimos.msgs.sensor_msgs.Image import Image, ImageFormat - - array = np.asarray(rgb) - if array.ndim != 3 or array.shape[2] < 3: - raise ValueError(f"expected HxWx3 RGB image, got shape {array.shape}") - # Leave Image.frame_id empty because the Rerun bridge logs the image at - # the canonical entity path (`world/color_image`) while CameraInfo logs - # the pinhole for that same entity. Setting a frame_id on the image would - # attach a second transform parent and Rerun rejects that. - image = Image.from_numpy(array[:, :, :3], format=ImageFormat.RGB, frame_id="") - camera_info = CameraInfo.from_fov( - fov_y_deg, - width=image.width, - height=image.height, - axis="vertical", - frame_id=frame_id, - ).with_ts(image.ts) - self._image_transport.broadcast(None, image) - if not self._camera_info_published: - self._camera_info_transport.broadcast(None, camera_info) - self._camera_info_published = True - - -def _publish_rerun_observations( - client: RuntimeSidecarClient, - response_observations: object, - publisher: RerunStreamPublisher | None, +def _dump_stream_observation_image( + capture: RuntimeStreamCapture, *, image_dump_dir: Path | None = None, image_dump_label: str = "frame", ) -> int: - if publisher is None and image_dump_dir is None: - return 0 - if not isinstance(response_observations, list): + if image_dump_dir is None: return 0 published = 0 - for frame in response_observations: - kind = getattr(frame, "kind", None) - data_ref = getattr(frame, "data_ref", None) - if kind != ObservationKind.IMAGE: - continue - if not isinstance(data_ref, str): - continue - payload = client.payload(data_ref) + image = capture.last_color_image + if image is not None: import numpy as np - image = np.load(BytesIO(payload), allow_pickle=False) - metadata = getattr(frame, "metadata", {}) - fov_y_deg = 45.0 - if isinstance(metadata, dict): - maybe_fov = metadata.get("fov_y_deg") - if isinstance(maybe_fov, int | float): - fov_y_deg = float(maybe_fov) - if metadata.get("image_convention") == "opengl": - display_image = np.flipud(image) - else: - display_image = image - else: - display_image = image - stream = getattr(frame, "stream", "camera") - frame_id = stream if isinstance(stream, str) else "camera" + from dimos.msgs.sensor_msgs.Image import Image + + if not isinstance(image, Image): + raise TypeError(f"expected DimOS Image stream output, got {type(image).__name__}") + display_image = np.asarray(image.to_rgb().data) + frame_id = getattr(image, "frame_id", "camera") or "camera" if image_dump_dir is not None: - _write_rgb_jpeg(image_dump_dir / f"{image_dump_label}_{frame_id}_raw.jpg", image) _write_rgb_jpeg( image_dump_dir / f"{image_dump_label}_{frame_id}_display.jpg", display_image, ) - if publisher is not None: - publisher.publish_rgb(display_image, fov_y_deg=fov_y_deg, frame_id=frame_id) - published += 1 + published += 1 return published +class RuntimeStreamCapture: + """Small local subscriber for RobosuiteRuntimeModule output streams.""" + + def __init__(self) -> None: + self.last_motor_state: object | None = None + self.last_color_image: object | None = None + self.last_camera_info: object | None = None + self.runtime_events: list[object] = [] + self.streams_since_mark: list[str] = [] + + def mark(self) -> None: + self.streams_since_mark = [] + + def color_image(self, image: object) -> None: + self.last_color_image = image + self.streams_since_mark.append("color_image") + + def motor_state(self, motor_state: object) -> None: + self.last_motor_state = motor_state + self.streams_since_mark.append("motor_state") + + def camera_info(self, camera_info: object) -> None: + self.last_camera_info = camera_info + self.streams_since_mark.append("camera_info") + + def runtime_event(self, event: object) -> None: + self.runtime_events.append(event) + stream = getattr(event, "stream", "runtime_event") + self.streams_since_mark.append(stream if isinstance(stream, str) else "runtime_event") + + def _write_rgb_jpeg(path: Path, rgb: object) -> None: import cv2 import numpy as np @@ -400,10 +273,20 @@ def main() -> None: default=None, help="Override Robosuite camera, e.g. agentview or robot0_eye_in_hand.", ) + parser.add_argument( + "--runtime-mode", + choices=("placed", "local"), + default="placed", + help=( + "Runtime execution mode. 'placed' builds the Robosuite blueprint and " + "runs the simulator module in its named Python project runtime; " + "'local' directly instantiates the runtime module in the current process." + ), + ) parser.add_argument( "--rerun", action="store_true", - help="Publish Robosuite camera payloads to Rerun through DimOS streams.", + help="Publish Robosuite camera stream outputs to Rerun through DimOS streams.", ) parser.add_argument( "--rerun-memory-limit", @@ -433,8 +316,8 @@ def main() -> None: type=int, default=25, help=( - "When --rerun is enabled, write every Nth fetched Robosuite camera payload " - "as raw/display JPEGs under artifacts/.../images. Use 1 for every tick, <=0 to disable." + "When --rerun is enabled, write every Nth Robosuite camera stream output " + "as display JPEGs under artifacts/.../images. Use 1 for every tick, <=0 to disable." ), ) args = parser.parse_args() @@ -472,6 +355,7 @@ def main() -> None: rerun_lcm_port=args.rerun_lcm_port, rerun_max_hz=args.rerun_max_hz, camera_jpeg_dump_every=args.camera_jpeg_dump_every, + runtime_mode=args.runtime_mode, ) except Exception as exc: print(json.dumps({"ok": False, "reason": str(exc)}, indent=2)) @@ -488,6 +372,7 @@ def run_demo_config( rerun_lcm_port: int = 0, rerun_max_hz: float = 10.0, camera_jpeg_dump_every: int = 25, + runtime_mode: str = "placed", ) -> Path: return _run_demo( config, @@ -497,6 +382,7 @@ def run_demo_config( rerun_lcm_port=rerun_lcm_port, rerun_max_hz=rerun_max_hz, camera_jpeg_dump_every=camera_jpeg_dump_every, + runtime_mode=runtime_mode, ) @@ -509,6 +395,7 @@ def run_demo(config_path: Path) -> Path: rerun_lcm_port=0, rerun_max_hz=10.0, camera_jpeg_dump_every=25, + runtime_mode="placed", ) @@ -521,38 +408,97 @@ def _run_demo( rerun_lcm_port: int, rerun_max_hz: float, camera_jpeg_dump_every: int, + runtime_mode: str, ) -> Path: + from dimos_robosuite_sidecar.module import RobosuiteRuntimeModule + artifact_dir = (REPO_ROOT / config.artifact_dir).resolve() client_image_dump_dir = artifact_dir / "images" / "client" - server_image_dump_dir = artifact_dir / "images" / "server" - sidecar = _start_robosuite_sidecar( - config, - server_image_dump_dir=server_image_dump_dir if camera_jpeg_dump_every > 0 else None, - image_dump_every=camera_jpeg_dump_every, - ) - client = RuntimeSidecarClient(f"http://{config.runtime_host}:{config.runtime_port}") + runtime_image_dump_dir = artifact_dir / "images" / "runtime" owner: MotorShmOwner | None = None coordinator: ControlCoordinator | None = None - rerun_publisher: RerunStreamPublisher | None = None - sidecar_output = "" + rerun_bridge: RuntimeRerunBridge | None = None cleanup_status: dict[str, object] = { "coordinator_stopped": False, + "module_coordinator_stopped": False, "shm_unlinked": False, - "sidecar_stopped": False, + "runtime_stopped": False, } selected_rerun_grpc_port: int | None = None selected_rerun_lcm_port: int | None = None + module_coordinator = None + if rerun: + # Start Rerun before constructing Robosuite/MuJoCo. Starting the viewer + # after MuJoCo visual contexts exist can corrupt subsequent offscreen + # camera buffers in visual mode. + selected_rerun_grpc_port = rerun_grpc_port if rerun_grpc_port > 0 else _free_tcp_port() + selected_rerun_lcm_port = rerun_lcm_port if rerun_lcm_port > 0 else _free_tcp_port() + rerun_bridge = RuntimeRerunBridge( + grpc_port=selected_rerun_grpc_port, + lcm_port=selected_rerun_lcm_port, + memory_limit=rerun_memory_limit, + max_hz=rerun_max_hz, + ) + rerun_bridge.start() try: - try: - health = _wait_sidecar_healthy(sidecar, client, timeout_s=20.0) - except RuntimeError as exc: - raise RuntimeError( - "Robosuite sidecar did not become healthy. If this environment does not " - "have Robosuite installed, run this script in the Robosuite sidecar env." - ) from exc - description = client.describe() + if runtime_mode == "placed": + from dimos_robosuite_sidecar.blueprint import robosuite_runtime_blueprint + + from dimos.core.coordination.module_coordinator import ModuleCoordinator + + blueprint = robosuite_runtime_blueprint( + env_name=config.env_name, + robot_id=config.robot_id, + robot_model=config.robot_model, + controller=config.controller, + control_freq=config.control_step_hz, + horizon=config.horizon, + camera_name=config.camera_name, + seed=config.seed, + visualize=config.visualize, + image_dump_dir=runtime_image_dump_dir if camera_jpeg_dump_every > 0 else None, + image_dump_every=camera_jpeg_dump_every, + ).global_config(viewer="none", n_workers=1) + module_coordinator = ModuleCoordinator.build(blueprint) + runtime = module_coordinator.get_instance(RobosuiteRuntimeModule) + else: + runtime = RobosuiteRuntimeModule( + env_name=config.env_name, + robot_id=config.robot_id, + robot_model=config.robot_model, + controller=config.controller, + control_freq=config.control_step_hz, + horizon=config.horizon, + camera_name=config.camera_name, + seed=config.seed, + visualize=config.visualize, + image_dump_dir=runtime_image_dump_dir if camera_jpeg_dump_every > 0 else None, + image_dump_every=camera_jpeg_dump_every, + ) + except Exception as exc: + if rerun_bridge is not None: + rerun_bridge.stop() + raise RuntimeError( + "RobosuiteRuntimeModule could not be deployed. For placed mode, prepare " + "packages/dimos-robosuite-sidecar as the named runtime environment; for " + "local debug mode, run from a Robosuite-capable package environment." + ) from exc + capture = RuntimeStreamCapture() + runtime.motor_state.subscribe(capture.motor_state) + runtime.color_image.subscribe(capture.color_image) + runtime.camera_info.subscribe(capture.camera_info) + runtime.runtime_event.subscribe(capture.runtime_event) + health: dict[str, object] = { + "ok": True, + "runtime": "RobosuiteRuntimeModule", + "transport": f"module-rpc-{runtime_mode}", + "http_runtime_api": False, + } + try: + description = runtime.describe() plan = resolve_runtime_plan(config, description) - reset = client.reset( + capture.mark() + reset = runtime.reset( EpisodeResetRequest( episode_id=plan.episode_id, task_id=plan.task_id, @@ -560,48 +506,41 @@ def _run_demo( ) ) - owner = MotorShmOwner(plan.shm_key, plan.motor_names) - owner.write_state([MotorState(q=0.0) for _ in plan.motor_names], sequence=0) - - hardware = HardwareComponent( - hardware_id=plan.robot_id, - hardware_type=HardwareType.WHOLE_BODY, - joints=plan.motor_names, - adapter_type="benchmark_runtime", - address=plan.shm_key, - adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, - ) task_name = f"servo_{plan.robot_id}" - coordinator = ControlCoordinator( - tick_rate=float(plan.control_step_hz), - publish_joint_state=False, - hardware=[hardware], - tasks=[ - TaskConfig( - name=task_name, - type="servo", - joint_names=plan.motor_names, - auto_start=True, - params={"timeout": 0.0, "default_positions": [0.0] * len(plan.motor_names)}, - ) - ], - ) - coordinator.start() - if rerun: - selected_rerun_grpc_port = rerun_grpc_port if rerun_grpc_port > 0 else _free_tcp_port() - selected_rerun_lcm_port = rerun_lcm_port if rerun_lcm_port > 0 else _free_tcp_port() - rerun_publisher = RerunStreamPublisher( - grpc_port=selected_rerun_grpc_port, - lcm_port=selected_rerun_lcm_port, - memory_limit=rerun_memory_limit, - max_hz=rerun_max_hz, + if not config.visualize: + owner = MotorShmOwner(plan.shm_key, plan.motor_names) + owner.write_state([MotorState(q=0.0) for _ in plan.motor_names], sequence=0) + + hardware = HardwareComponent( + hardware_id=plan.robot_id, + hardware_type=HardwareType.WHOLE_BODY, + joints=plan.motor_names, + adapter_type="benchmark_runtime", + address=plan.shm_key, + adapter_kwargs={"motor_names": plan.motor_names, "connect_timeout_s": 5.0}, ) - rerun_publisher.start() + coordinator = ControlCoordinator( + tick_rate=float(plan.control_step_hz), + publish_joint_state=False, + hardware=[hardware], + tasks=[ + TaskConfig( + name=task_name, + type="servo", + joint_names=plan.motor_names, + auto_start=True, + params={ + "timeout": 0.0, + "default_positions": [0.0] * len(plan.motor_names), + }, + ) + ], + ) + coordinator.start() + if rerun: if camera_jpeg_dump_every > 0: - _publish_rerun_observations( - client, - reset.observations, - None, + _dump_stream_observation_image( + capture, image_dump_dir=client_image_dump_dir, image_dump_label="reset", ) @@ -612,45 +551,70 @@ def _run_demo( target = _target_for_tick( plan.target_position, len(plan.motor_names), tick, config.visualize ) - accepted = coordinator.task_invoke( - task_name, "set_target", {"positions": target, "t_now": None} - ) - if accepted is not True: - raise RuntimeError(f"servo task rejected target at tick {tick}") - time.sleep(1.0 / plan.control_step_hz) - command_sequence, action = _command_frame(owner, plan.robot_id) - response = client.step( + if config.visualize: + # The visual smoke path is intended to make simulator motion obvious. + # The ControlCoordinator/SHM servo path intentionally damps targets, + # which is useful for normal benchmark plumbing but too subtle for + # visual debugging. Send the runtime action directly here while the + # non-visual smoke keeps exercising the coordinator bridge. + command_sequence = tick + 1 + action = MotorActionFrame( + robot_id=plan.robot_id, + names=plan.motor_names, + q=target, + sequence=command_sequence, + ) + time.sleep(1.0 / plan.control_step_hz) + else: + if coordinator is None: + raise RuntimeError("non-visual demo requires a ControlCoordinator") + accepted = coordinator.task_invoke( + task_name, "set_target", {"positions": target, "t_now": None} + ) + if accepted is not True: + raise RuntimeError(f"servo task rejected target at tick {tick}") + time.sleep(1.0 / plan.control_step_hz) + if owner is None: + raise RuntimeError("non-visual demo requires a motor SHM owner") + command_sequence, action = _command_frame(owner, plan.robot_id) + capture.mark() + response = runtime.step( StepRequest(episode_id=plan.episode_id, tick_id=tick, action=action) ) + motor_state = capture.last_motor_state + if not isinstance(motor_state, MotorStateFrame): + raise RuntimeError("RobosuiteRuntimeModule did not publish motor_state") + motor_state = cast("MotorStateFrame", motor_state) should_dump_jpeg = ( rerun and camera_jpeg_dump_every > 0 and tick % camera_jpeg_dump_every == 0 ) - published_rerun_frames += _publish_rerun_observations( - client, - response.observations, - rerun_publisher, + if rerun and capture.last_color_image is not None: + published_rerun_frames += 1 + _dump_stream_observation_image( + capture, image_dump_dir=client_image_dump_dir if should_dump_jpeg else None, image_dump_label=f"tick_{tick:06d}", ) - owner.write_state( - [ - MotorState( - q=response.motor_state.q[i], - dq=response.motor_state.dq[i], - tau=response.motor_state.tau[i], - ) - for i in range(len(plan.motor_names)) - ], - sequence=response.motor_state.sequence, - ) + if owner is not None: + owner.write_state( + [ + MotorState( + q=motor_state.q[i], + dq=motor_state.dq[i], + tau=motor_state.tau[i], + ) + for i in range(len(plan.motor_names)) + ], + sequence=motor_state.sequence, + ) trace.append( { "tick": tick, "command_sequence": command_sequence, - "state_sequence": response.motor_state.sequence, + "state_sequence": motor_state.sequence, "command_q": action.q, - "state_q": response.motor_state.q, - "observation_streams": [frame.stream for frame in response.observations], + "state_q": motor_state.q, + "observation_streams": list(capture.streams_since_mark), "rerun_frames_published": published_rerun_frames, "reward": response.reward, "done": response.done, @@ -664,7 +628,7 @@ def _run_demo( print("visual demo complete; keeping Robosuite viewer open for 5 seconds") time.sleep(5.0) - score = client.score() + score = runtime.score() write_json(artifact_dir / "episode_config.json", config) write_json(artifact_dir / "runtime_description.json", description) write_json(artifact_dir / "resolved_runtime_plan.json", plan) @@ -683,7 +647,7 @@ def _run_demo( "client_jpeg_dump_dir": str(client_image_dump_dir) if rerun and camera_jpeg_dump_every > 0 else None, - "server_jpeg_dump_dir": str(server_image_dump_dir) + "runtime_jpeg_dump_dir": str(runtime_image_dump_dir) if camera_jpeg_dump_every > 0 else None, "jpeg_dump_every": camera_jpeg_dump_every, @@ -693,9 +657,9 @@ def _run_demo( write_json(artifact_dir / "health.json", health) return artifact_dir finally: - if rerun_publisher is not None: + if rerun_bridge is not None: try: - rerun_publisher.stop() + rerun_bridge.stop() cleanup_status["rerun_stopped"] = True except Exception as exc: cleanup_status["rerun_error"] = str(exc) @@ -712,18 +676,21 @@ def _run_demo( cleanup_status["shm_unlinked"] = True except Exception as exc: cleanup_status["shm_error"] = str(exc) - sidecar.terminate() - try: - sidecar_output, _ = sidecar.communicate(timeout=2.0) - except subprocess.TimeoutExpired: - sidecar.kill() - sidecar_output, _ = sidecar.communicate(timeout=2.0) - cleanup_status["sidecar_returncode"] = sidecar.returncode - cleanup_status["sidecar_stopped"] = sidecar.returncode is not None - sidecar_log = artifact_dir / "robosuite_sidecar.log" - sidecar_log.parent.mkdir(parents=True, exist_ok=True) - sidecar_log.write_text(sidecar_output) - write_json(sidecar_log.parent / "cleanup_status.json", cleanup_status) + if module_coordinator is not None: + try: + module_coordinator.stop() + cleanup_status["module_coordinator_stopped"] = True + cleanup_status["runtime_stopped"] = True + except Exception as exc: + cleanup_status["module_coordinator_error"] = str(exc) + else: + try: + runtime.stop() + cleanup_status["runtime_stopped"] = True + except Exception as exc: + cleanup_status["runtime_error"] = str(exc) + artifact_dir.mkdir(parents=True, exist_ok=True) + write_json(artifact_dir / "cleanup_status.json", cleanup_status) if __name__ == "__main__": From bafdac7df0de2b3c956bdab47f874d559354336d Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 30 Jun 2026 16:20:01 -0700 Subject: [PATCH 107/110] spec: openarm mini teleoperation --- CONTEXT.md | 16 ++ .../add-openarm-mini-teleop/.openspec.yaml | 2 + .../changes/add-openarm-mini-teleop/design.md | 163 ++++++++++++++++++ .../add-openarm-mini-teleop/proposal.md | 30 ++++ .../specs/openarm-mini-teleop/spec.md | 67 +++++++ .../specs/teleop-adapter-runtime/spec.md | 71 ++++++++ .../changes/add-openarm-mini-teleop/tasks.md | 39 +++++ 7 files changed, 388 insertions(+) create mode 100644 openspec/changes/add-openarm-mini-teleop/.openspec.yaml create mode 100644 openspec/changes/add-openarm-mini-teleop/design.md create mode 100644 openspec/changes/add-openarm-mini-teleop/proposal.md create mode 100644 openspec/changes/add-openarm-mini-teleop/specs/openarm-mini-teleop/spec.md create mode 100644 openspec/changes/add-openarm-mini-teleop/specs/teleop-adapter-runtime/spec.md create mode 100644 openspec/changes/add-openarm-mini-teleop/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md index dbd77aef49..a8f5b24721 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -335,3 +335,19 @@ _Avoid_: Treating a bus as owned by a single joint group when multiple groups ma **OpenArm**: An OpenArm robot configuration built from Damiao motors, with OpenArm-specific joints, side naming, limits, and robot description. _Avoid_: Damiao robot when referring to OpenArm-specific geometry or naming + +**Teleop adapter**: +A device-specific bridge from a human teleoperation source, such as a headset controller, phone, keyboard, or physical leader arm, into DimOS coordinator-facing command streams. +_Avoid_: teleop backend when the component emits coordinator command types, teleop module when referring only to the device adapter, controller when the device is not a robot controller + +**Teleop profile**: +A configured teleoperation behavior that selects one primary way human intent drives robot motion while allowing secondary engagement, status, and diagnostic signals. +_Avoid_: backend when referring to behavior selection, mode when the distinction affects routing and safety semantics + +**Primary motion output**: +The single motion-control path a teleoperation behavior uses to drive robot movement, so one human input source does not unintentionally command the same robot through multiple motion abstractions at once. +_Avoid_: all active outputs, debug stream, secondary status output + +**Teleop command envelope**: +A small wrapper around a coordinator-facing teleoperation command that distinguishes an active command from no command and from an explicit stop command. +_Avoid_: using a missing command as a stop signal, overloading raw motion-message contents with teleop authority state diff --git a/openspec/changes/add-openarm-mini-teleop/.openspec.yaml b/openspec/changes/add-openarm-mini-teleop/.openspec.yaml new file mode 100644 index 0000000000..d6b53dee55 --- /dev/null +++ b/openspec/changes/add-openarm-mini-teleop/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/add-openarm-mini-teleop/design.md b/openspec/changes/add-openarm-mini-teleop/design.md new file mode 100644 index 0000000000..f212e4ad2b --- /dev/null +++ b/openspec/changes/add-openarm-mini-teleop/design.md @@ -0,0 +1,163 @@ +## Context + +DimOS teleoperation currently has device-specific modules such as Quest teleop that publish directly to coordinator-facing streams (`PoseStamped`, `Twist`, and `Buttons`). Manipulator control flows through `ControlCoordinator`, whose stable generic inputs include `joint_command: In[JointState]`, `coordinator_cartesian_command: In[PoseStamped]`, `twist_command: In[Twist]`, and `teleop_buttons: In[Buttons]`. + +OpenArm Mini is a physical leader-arm teleoperator. For v1 it will only teleoperate OpenArm followers, so DimOS does not need to introduce a new coordinator array-command input or make OpenArm Mini robot-agnostic. The OpenArm Mini integration should still avoid duplicating common DimOS teleop lifecycle, publishing, and structural safety logic. + +LeRobot's OpenArm Mini implementation is a useful behavioral reference, but DimOS should not depend on LeRobot at runtime. The OpenArm Mini adapter will depend on the lower-level Feetech motor communication library and implement the small amount of OpenArm Mini mapping/calibration logic directly. + +## Goals / Non-Goals + +**Goals:** + +- Add a reusable `TeleopModule` shell that can host device-specific teleop adapters and publish to the existing `ControlCoordinator` inputs. +- Add a `TeleopAdapter` contract with `connect()`, `disconnect()`, and `get_current_command()`. +- Represent adapter output with a command envelope that distinguishes active commands, no command/no authority, and explicit stop commands. +- Enforce one primary motion output per adapter instance: `JointState`, `PoseStamped`, or `Twist`. +- Implement OpenArm Mini → OpenArm joint mirror teleop by emitting follower `JointState` commands. +- Keep OpenArm Mini runtime startup non-interactive and fail fast when required calibration artifacts are missing. +- Provide a manual calibration/demo script for OpenArm Mini leader setup. +- Keep existing Quest teleop implementation unchanged in v1. + +**Non-Goals:** + +- Do not migrate Quest teleop to the new `TeleopModule` in v1. +- Do not add a LeRobot runtime dependency. +- Do not add a new `ControlCoordinator` array-command input in v1. +- Do not make OpenArm Mini a generic robot-agnostic leader in v1; the adapter owns OpenArm-specific mapping. +- Do not make calibration a required method on all teleop adapters. +- Do not start follower OpenArm hardware or `ControlCoordinator` from the calibration/demo script. + +## Decisions + +### Introduce `TeleopModule` plus `TeleopAdapter` + +`TeleopModule` is the DimOS module shell. It owns module lifecycle, periodic command retrieval, structural safety, and publishing to coordinator-facing outputs. `TeleopAdapter` is the device-specific bridge from a human teleoperation source into coordinator-native command objects. + +The adapter interface is intentionally small: + +```python +class TeleopAdapter(Protocol): + primary_output: TeleopPrimaryOutput + + def connect(self) -> None: ... + def disconnect(self) -> None: ... + def get_current_command(self) -> TeleopCommand | None: ... +``` + +`get_current_command()` is used instead of `poll()` because input sources vary: serial leader arms are sampled, WebSocket controllers are event-updated, keyboard state may be callback-maintained, and gamepads are often polled. The module asks for the current command on each control tick; the adapter may return the same command repeatedly while authority is active, or `None` when no command should be published. + +Alternative considered: a pure backend that emits normalized samples and a separate profile/binding layer. This was more reusable but too much abstraction for v1, especially because OpenArm Mini will initially only target OpenArm. + +### Use a command envelope instead of raw messages or `None` overloading + +Adapters return either `None` or a `TeleopCommand`: + +```python +@dataclass(frozen=True) +class TeleopCommand: + command: JointState | PoseStamped | Twist + stop: bool = False + metadata: TeleopCommandMetadata | None = None +``` + +`None` means no command/no authority. `TeleopCommand(command=...)` means an active command. `TeleopCommand(command=..., stop=True)` means an explicit stop command. The module must not treat a missing command as a stop signal. + +Alternative considered: returning raw coordinator messages. This loses the ability to distinguish no authority from explicit stop without overloading message contents. + +### Publish to existing coordinator streams + +`TeleopModule` exposes the stable superset of coordinator-facing outputs: + +- `joint_command: Out[JointState]` +- `coordinator_cartesian_command: Out[PoseStamped]` +- `twist_command: Out[Twist]` +- optional non-motion outputs such as `teleop_buttons` and leader/debug/status streams as needed + +The adapter declares one primary motion output. The module publishes each active command only to the matching output and rejects adapters that try to mix primary motion abstractions. + +Alternative considered: dynamic module IO based on configuration. DimOS supports configuration-resolved IO, but a stable superset of outputs is simpler because `ControlCoordinator` already has predefined inputs. + +### Split generic and adapter-specific safety + +`TeleopModule` owns structural safety: + +- do not publish when command is `None` +- enforce max publish rate +- enforce stale command timeout +- handle explicit stop commands +- enforce one primary motion output per adapter +- stop safely during module shutdown + +`OpenArmMiniTeleopAdapter` owns safety and validation requiring device or robot meaning: + +- calibration artifact validity +- OpenArm Mini → OpenArm unit conversion +- OpenArm-specific sign/order mapping +- joint_6 / joint_7 remap +- gripper conversion +- OpenArm follower joint names +- OpenArm joint limits +- leader/follower jump threshold when measured in joint space + +Alternative considered: putting all safety in the generic module. This would force generic code to understand robot-specific units and joint semantics. + +### Implement OpenArm Mini directly on the Feetech library + +DimOS will implement OpenArm Mini teleop directly using the lower-level Feetech motor communication library. It will not import LeRobot at runtime. The implementation should mirror the relevant OpenArm Mini behavior discovered from LeRobot: two physical serial buses, side-specific transforms, joint_6/joint_7 remap, gripper conversion, and saved calibration. + +The dependency should live in a narrow optional extra for OpenArm Mini teleop rather than broad `manipulation`, so users who do not use this device do not install serial servo dependencies. + +Alternative considered: wrapping LeRobot's `OpenArmMini`. This would reduce code, but it would make DimOS depend on LeRobot packaging and all of its teleoperator assumptions for a small device-specific bridge. + +### Keep calibration outside normal blueprint startup + +Runtime OpenArm Mini teleop startup is non-interactive: + +- resolve side-specific calibration directories +- load calibration artifacts +- connect/configure Feetech buses +- fail fast with a clear message if calibration is missing or invalid + +Calibration is a special OpenArm Mini maintenance workflow, not part of the generic `TeleopAdapter` contract. A manual script such as `dimos/teleop/openarm_mini/demo_calibrate_openarm_mini.py` performs interactive setup/calibration for the leader only, writes calibration artifacts, and may optionally print live leader readings. + +Default calibration directories are side-specific and under DimOS state storage: + +```text +STATE_DIR / "teleop" / "openarm_mini" / "left" +STATE_DIR / "teleop" / "openarm_mini" / "right" +``` + +The OpenArm Mini config uses side-specific names: + +```python +port_left: str = "/dev/ttyUSB1" +port_right: str = "/dev/ttyUSB0" +left_calibration_path: Path | None = None +right_calibration_path: Path | None = None +``` + +Alternative considered: storing calibration in cache or using a `teleop_id` directory. Calibration is persistent device state, not cache, and v1 does not need a user-defined teleop identity. + +## Risks / Trade-offs + +- Feetech library packaging/name may differ from LeRobot internals → verify the package and import surface before implementation; keep imports localized to the OpenArm Mini adapter with a clear missing-extra error. +- Directly owning OpenArm Mini transforms can drift from upstream LeRobot behavior → document the transform rules in code and add unit tests for mapping, remap, sign, and gripper conversion. +- Hardcoding OpenArm Mini → OpenArm mapping limits reuse → acceptable for v1; introduce profiles/bindings or ordered array commands only when a second follower or behavior needs them. +- Calibration mistakes can cause unsafe leader/follower jumps → default runtime must refuse missing/invalid calibration, and the adapter should enforce jump/limit checks before publishing. +- Adding a generic `TeleopModule` without migrating Quest may leave two teleop patterns temporarily → acceptable to keep v1 focused and avoid destabilizing Quest. + +## Migration Plan + +1. Add the teleop adapter runtime types and module without changing existing Quest modules. +2. Add OpenArm Mini adapter, config, calibration script, tests, and blueprint. +3. Add the narrow optional dependency extra for OpenArm Mini teleop. +4. Regenerate the blueprint registry after adding the new blueprint. +5. Validate with unit tests and non-hardware startup/error-path tests; hardware validation requires calibrated OpenArm Mini and OpenArm devices. + +Rollback is straightforward: remove the new blueprint from use. Existing Quest and keyboard teleop paths remain unchanged. + +## Open Questions + +- Exact Feetech Python package name and import surface must be verified during implementation. +- Exact OpenArm Mini calibration JSON schema should be finalized while implementing the calibration script and adapter loader. diff --git a/openspec/changes/add-openarm-mini-teleop/proposal.md b/openspec/changes/add-openarm-mini-teleop/proposal.md new file mode 100644 index 0000000000..abf02bc2ce --- /dev/null +++ b/openspec/changes/add-openarm-mini-teleop/proposal.md @@ -0,0 +1,30 @@ +## Why + +DimOS has Quest and keyboard teleoperation paths, but it does not support using an OpenArm Mini physical leader as a direct teleoperation input for OpenArm manipulators. Adding this path enables low-latency joint mirror teleoperation while establishing a small reusable teleop adapter shell for future devices. + +## What Changes + +- Add a generic teleoperation module shell that owns DimOS lifecycle, periodic command retrieval, coordinator-facing publishing, and structural safety checks. +- Add a teleop adapter contract where device-specific adapters connect to a human input source and return a command envelope containing exactly one primary coordinator-facing motion command type. +- Add an OpenArm Mini teleop adapter that reads Feetech-based leader arm state and emits OpenArm follower `JointState` commands. +- Add non-interactive OpenArm Mini runtime calibration loading with side-specific default calibration directories under DimOS state storage. +- Add a manual OpenArm Mini calibration/demo script that performs interactive leader calibration outside normal blueprint startup. +- Add an OpenArm Mini teleop blueprint that connects the generic teleop module to the existing OpenArm control coordinator through `joint_command`. +- Keep existing Quest teleop behavior unchanged in v1. + +## Capabilities + +### New Capabilities +- `teleop-adapter-runtime`: Defines reusable teleoperation adapter/module behavior, command envelopes, safety ownership, and coordinator stream publishing. +- `openarm-mini-teleop`: Supports OpenArm Mini leader-arm teleoperation of OpenArm followers, including calibration storage, direct Feetech integration, and blueprint wiring. + +### Modified Capabilities + +None. + +## Impact + +- Affected code areas: `dimos/teleop/`, OpenArm manipulator blueprints, generated blueprint registry, and optional dependency metadata. +- Adds a narrow optional dependency for the Feetech motor communication library used by OpenArm Mini teleop. +- Does not add a LeRobot runtime dependency; LeRobot remains only a reference for OpenArm Mini behavior. +- Does not change `ControlCoordinator` inputs or existing Quest teleop modules for v1. diff --git a/openspec/changes/add-openarm-mini-teleop/specs/openarm-mini-teleop/spec.md b/openspec/changes/add-openarm-mini-teleop/specs/openarm-mini-teleop/spec.md new file mode 100644 index 0000000000..f2b39d984f --- /dev/null +++ b/openspec/changes/add-openarm-mini-teleop/specs/openarm-mini-teleop/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: OpenArm Mini direct Feetech integration +The system SHALL implement OpenArm Mini teleoperation using the lower-level Feetech motor communication library rather than a LeRobot runtime dependency. + +#### Scenario: OpenArm Mini adapter is imported without LeRobot +- **WHEN** the OpenArm Mini teleop adapter is imported in an environment without LeRobot installed +- **THEN** the import does not fail because of a missing LeRobot package + +#### Scenario: Feetech dependency is missing +- **WHEN** OpenArm Mini teleop is started without the required Feetech dependency installed +- **THEN** the system fails with a clear message identifying the OpenArm Mini optional dependency needed to use the adapter + +### Requirement: OpenArm Mini non-interactive runtime startup +The OpenArm Mini teleop adapter SHALL load calibration artifacts non-interactively during normal blueprint startup and fail fast when required calibration is missing or invalid. + +#### Scenario: Calibration artifacts exist +- **WHEN** both side-specific OpenArm Mini calibration directories contain valid calibration artifacts +- **THEN** the OpenArm Mini teleop adapter loads them without prompting the user and proceeds with connection + +#### Scenario: Calibration artifact is missing +- **WHEN** a required OpenArm Mini calibration artifact is missing during normal blueprint startup +- **THEN** the adapter fails fast with a clear error explaining how to run the manual calibration/demo script + +### Requirement: OpenArm Mini side-specific calibration paths +The OpenArm Mini teleop adapter SHALL support side-specific calibration path configuration with DimOS state-directory defaults. + +#### Scenario: Calibration paths are omitted +- **WHEN** `left_calibration_path` and `right_calibration_path` are not configured +- **THEN** the adapter resolves them to `STATE_DIR / "teleop" / "openarm_mini" / "left"` and `STATE_DIR / "teleop" / "openarm_mini" / "right"` + +#### Scenario: Calibration paths are configured +- **WHEN** `left_calibration_path` or `right_calibration_path` is configured explicitly +- **THEN** the adapter uses the configured path for that side instead of the default DimOS state-directory path + +### Requirement: OpenArm Mini to OpenArm joint command mapping +The OpenArm Mini teleop adapter SHALL convert OpenArm Mini leader readings into OpenArm follower `JointState` commands using OpenArm follower joint names and adapter-owned mapping rules. + +#### Scenario: Leader joint readings are available +- **WHEN** calibrated OpenArm Mini leader joint readings are available and teleop authority is active +- **THEN** the adapter returns a `JointState` command envelope whose joint names match the target OpenArm follower joints + +#### Scenario: OpenArm Mini transform rules are applied +- **WHEN** the adapter converts leader readings into follower commands +- **THEN** it applies the OpenArm Mini side-specific sign/order mapping, joint_6/joint_7 remap, gripper conversion, and OpenArm joint limits before returning the command + +### Requirement: Manual OpenArm Mini calibration/demo script +The system SHALL provide a manual OpenArm Mini calibration/demo script for interactive leader setup outside normal teleop runtime. + +#### Scenario: Calibration script is run +- **WHEN** the OpenArm Mini calibration/demo script is run by an operator +- **THEN** it connects only to the OpenArm Mini leader hardware, performs interactive setup/calibration, and writes side-specific calibration artifacts + +#### Scenario: Calibration script is run near follower hardware +- **WHEN** the calibration/demo script is running +- **THEN** it does not connect to follower OpenArm hardware and does not start `ControlCoordinator` + +### Requirement: OpenArm Mini teleop blueprint +The system SHALL provide an OpenArm Mini teleop blueprint that wires the generic teleop module to the existing OpenArm control coordinator through `joint_command`. + +#### Scenario: Blueprint is listed +- **WHEN** DimOS blueprints are listed after the OpenArm Mini teleop blueprint is added and the generated registry is refreshed +- **THEN** the OpenArm Mini teleop blueprint appears with the expected blueprint name + +#### Scenario: Blueprint is built +- **WHEN** the OpenArm Mini teleop blueprint is built +- **THEN** the teleop module's `joint_command` output is connected to the coordinator's `joint_command` input diff --git a/openspec/changes/add-openarm-mini-teleop/specs/teleop-adapter-runtime/spec.md b/openspec/changes/add-openarm-mini-teleop/specs/teleop-adapter-runtime/spec.md new file mode 100644 index 0000000000..21d31b46d7 --- /dev/null +++ b/openspec/changes/add-openarm-mini-teleop/specs/teleop-adapter-runtime/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Teleop adapter contract +The system SHALL provide a teleop adapter contract for device-specific bridges that connect to a human input source, disconnect from it, and report the current teleoperation command without publishing directly to DimOS streams. + +#### Scenario: Module retrieves command from adapter +- **WHEN** a teleop adapter is connected and the teleop module control tick runs +- **THEN** the teleop module calls the adapter's current-command method and remains responsible for publishing any returned command + +#### Scenario: Adapter has no authority +- **WHEN** the adapter returns no command for the current tick +- **THEN** the teleop module publishes no motion command for that tick + +### Requirement: Teleop command envelope +The system SHALL represent adapter output with a command envelope that distinguishes an active coordinator-facing command from no command and from an explicit stop command. + +#### Scenario: Active command is returned +- **WHEN** an adapter returns a command envelope containing a `JointState`, `PoseStamped`, or `Twist` with no stop flag +- **THEN** the teleop module treats the envelope as an active motion command + +#### Scenario: Explicit stop command is returned +- **WHEN** an adapter returns a command envelope with the stop flag set +- **THEN** the teleop module treats the envelope as an explicit stop rather than as missing authority + +### Requirement: Single primary motion output +The system SHALL require each teleop adapter instance to declare exactly one primary motion output type among joint, cartesian, and twist commands. + +#### Scenario: Adapter declares one primary output +- **WHEN** a teleop adapter declares one primary motion output type +- **THEN** the teleop module publishes active commands only to the matching coordinator-facing output stream + +#### Scenario: Adapter attempts conflicting primary outputs +- **WHEN** a teleop adapter configuration attempts to use more than one primary motion output type +- **THEN** the teleop module rejects the adapter configuration before publishing motion commands + +### Requirement: Coordinator-facing stream publishing +The system SHALL expose coordinator-facing teleop outputs compatible with existing `ControlCoordinator` inputs without requiring changes to `ControlCoordinator` for v1. + +#### Scenario: Joint command adapter emits command +- **WHEN** a joint-primary adapter returns an active `JointState` command envelope +- **THEN** the teleop module publishes the `JointState` on `joint_command` + +#### Scenario: Cartesian command adapter emits command +- **WHEN** a cartesian-primary adapter returns an active `PoseStamped` command envelope +- **THEN** the teleop module publishes the `PoseStamped` on `coordinator_cartesian_command` + +#### Scenario: Twist command adapter emits command +- **WHEN** a twist-primary adapter returns an active `Twist` command envelope +- **THEN** the teleop module publishes the `Twist` on `twist_command` + +### Requirement: Generic structural teleop safety +The teleop module SHALL enforce structural safety checks that do not require device-specific or robot-specific interpretation. + +#### Scenario: Command stream becomes stale +- **WHEN** the adapter has not produced a valid command within the configured stale-command timeout +- **THEN** the teleop module stops publishing active motion commands until a valid command is available again + +#### Scenario: Publish rate is configured +- **WHEN** the teleop module runs with a configured maximum publish rate +- **THEN** the teleop module does not publish active motion commands faster than that rate + +#### Scenario: Module stops +- **WHEN** the teleop module is stopped +- **THEN** it disconnects the adapter and performs any configured structural stop handling + +### Requirement: Existing Quest teleop remains unchanged in v1 +The system SHALL keep existing Quest teleop modules and Quest teleop blueprints functionally unchanged while adding the v1 teleop adapter runtime. + +#### Scenario: Existing Quest blueprint is run +- **WHEN** an existing Quest teleop blueprint is used after this change +- **THEN** it continues to use its existing Quest module implementation rather than the new generic teleop module diff --git a/openspec/changes/add-openarm-mini-teleop/tasks.md b/openspec/changes/add-openarm-mini-teleop/tasks.md new file mode 100644 index 0000000000..feac7e7afa --- /dev/null +++ b/openspec/changes/add-openarm-mini-teleop/tasks.md @@ -0,0 +1,39 @@ +## 1. Teleop Adapter Runtime + +- [ ] 1.1 Add teleop runtime package structure under `dimos/teleop/` without changing existing Quest modules. +- [ ] 1.2 Define `TeleopPrimaryOutput`, `TeleopCommandMetadata`, `TeleopCommand`, and `TeleopAdapter` types with `connect()`, `disconnect()`, and `get_current_command()`. +- [ ] 1.3 Implement a generic `TeleopModule` with stable coordinator-facing outputs for `joint_command`, `coordinator_cartesian_command`, and `twist_command`. +- [ ] 1.4 Implement structural safety in `TeleopModule`: no publish on `None`, one primary output, max publish rate, stale-command timeout, explicit stop handling, and adapter disconnect on stop. +- [ ] 1.5 Add unit tests for command envelope handling, output routing, conflicting primary output rejection, stale timeout behavior, rate limiting, and stop/disconnect behavior. + +## 2. OpenArm Mini Feetech Integration + +- [ ] 2.1 Verify the lower-level Feetech Python package name/import surface and add it to a narrow OpenArm Mini optional extra in `pyproject.toml`. +- [ ] 2.2 Add localized missing-dependency handling so OpenArm Mini teleop fails with a clear install hint when the Feetech extra is not installed. +- [ ] 2.3 Implement OpenArm Mini config with `port_left`, `port_right`, `left_calibration_path`, and `right_calibration_path` plus DimOS `STATE_DIR / "teleop" / "openarm_mini" / ` defaults. +- [ ] 2.4 Implement OpenArm Mini calibration artifact load/save models and validation for side-specific calibration directories. +- [ ] 2.5 Implement Feetech bus connection/configuration for both OpenArm Mini sides using non-interactive calibration loading during runtime startup. +- [ ] 2.6 Implement OpenArm Mini leader reading, side-specific sign/order mapping, joint_6/joint_7 remap, gripper conversion, OpenArm follower joint naming, joint-limit checks, and jump-threshold checks. +- [ ] 2.7 Implement `OpenArmMiniTeleopAdapter.get_current_command()` to return `JointState` command envelopes only when teleop authority is active and leader readings are valid. +- [ ] 2.8 Add unit tests for calibration path defaults, missing/invalid calibration errors, transform/remap/gripper conversion, follower joint names, joint limits, and jump-threshold rejection. + +## 3. Manual Calibration Demo + +- [ ] 3.1 Add `dimos/teleop/openarm_mini/demo_calibrate_openarm_mini.py` as a manual script excluded from pytest collection by name. +- [ ] 3.2 Implement interactive OpenArm Mini leader setup/calibration UX that writes side-specific calibration artifacts and never connects to follower OpenArm hardware. +- [ ] 3.3 Add an optional live-readout mode to the demo script for inspecting calibrated leader positions without starting `ControlCoordinator`. +- [ ] 3.4 Document the calibration script usage and default calibration storage paths near the OpenArm Mini teleop code. + +## 4. Blueprint Wiring + +- [ ] 4.1 Add an OpenArm Mini teleop blueprint that connects the generic teleop module to the existing OpenArm control coordinator through `joint_command`. +- [ ] 4.2 Keep existing Quest teleop blueprints unchanged and verify they still reference their existing Quest modules. +- [ ] 4.3 Regenerate `dimos/robot/all_blueprints.py` with the existing blueprint generation test after adding the new blueprint. +- [ ] 4.4 Add blueprint build/list tests or update existing blueprint generation coverage so the new blueprint appears and wires `joint_command` correctly. + +## 5. Validation + +- [ ] 5.1 Run focused unit tests for the teleop adapter runtime and OpenArm Mini adapter mapping/calibration logic. +- [ ] 5.2 Run the blueprint generation test for `dimos/robot/all_blueprints.py`. +- [ ] 5.3 Run the relevant fast pytest subset for teleop/OpenArm modules. +- [ ] 5.4 Perform hardware validation with calibrated OpenArm Mini and OpenArm follower when hardware is available, including missing-calibration and explicit-stop checks. From 8d2cdbefab633e976ad142fc8f67bc5b628e0a0e Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 6 Jul 2026 13:42:11 -0700 Subject: [PATCH 108/110] feat: add manual agentic grasp demo --- .../agentic_manipulation_module.py | 510 +++++++++++++++++- .../manipulation/agentic_manipulation_spec.py | 28 + .../grasping/manual_agentic_gpd_grasp_demo.py | 140 +++++ dimos/manipulation/manipulation_module.py | 229 ++++++++ .../test_agentic_manipulation_module.py | 460 +++++++++++++++- dimos/manipulation/test_manipulation_unit.py | 48 ++ dimos/msgs/grasping_msgs/GraspDebugMarkers.py | 232 ++++++++ dimos/perception/object_scene_registration.py | 14 +- dimos/robot/all_blueprints.py | 2 + .../xarm/blueprints/simulation.py | 98 ++++ .../blueprints/test_gpd_mujoco_grasp_demo.py | 141 +++++ dimos/simulation/engines/mujoco_engine.py | 9 + dimos/simulation/engines/mujoco_sim_module.py | 69 ++- .../engines/test_mujoco_sim_module.py | 43 +- dimos/visualization/rerun/bridge.py | 7 +- dimos/visualization/rerun/urdf.py | 167 ++++++ docs/usage/README.md | 1 + docs/usage/gpd_mujoco_grasp_demo.md | 46 ++ .../manual-agentic-grasp-demo/tasks.md | 40 +- packages/dimos-gpd-grasp-demo/uv.lock | 30 +- 20 files changed, 2248 insertions(+), 66 deletions(-) create mode 100644 dimos/manipulation/grasping/manual_agentic_gpd_grasp_demo.py create mode 100644 dimos/msgs/grasping_msgs/GraspDebugMarkers.py create mode 100644 dimos/visualization/rerun/urdf.py diff --git a/dimos/manipulation/agentic_manipulation_module.py b/dimos/manipulation/agentic_manipulation_module.py index d466369b08..dbd3cdf3c3 100644 --- a/dimos/manipulation/agentic_manipulation_module.py +++ b/dimos/manipulation/agentic_manipulation_module.py @@ -12,15 +12,35 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Universal agent-facing manipulation primitive facade.""" +"""Agent-facing manipulation primitive facades.""" from __future__ import annotations +import numpy as np + from dimos.agents.annotation import skill from dimos.agents.skill_result import SkillResult from dimos.core.module import Module -from dimos.manipulation.agentic_manipulation_spec import ManipulationControlSpec +from dimos.core.stream import Out +from dimos.manipulation.agentic_manipulation_spec import ( + AgenticGraspGenSpec, + ManipulationControlSpec, +) from dimos.manipulation.skill_errors import ManipulationSkillError +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseArray import PoseArray +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.grasping_msgs.GraspDebugMarkers import GraspDebugMarkers +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.perception.object_scene_registration_spec import ObjectSceneRegistrationSpec +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +SUPPORTED_RELATIVE_FRAME = "world" +SUPPORTED_GRASP_FRAME = "world" +DEFAULT_PREGRASP_OFFSET_M = 0.10 +DEFAULT_LIFT_DISTANCE_M = 0.10 class AgenticManipulationModule(Module): @@ -93,4 +113,490 @@ def close_gripper(self, robot_name: str | None = None) -> SkillResult[Manipulati return self._manipulation.close_gripper(robot_name) +class AgenticGraspManipulationModule(AgenticManipulationModule): + """Expose grasp-capable manipulation primitives for manual agent-facing demos.""" + + _scene_registration: ObjectSceneRegistrationSpec + _grasp_gen: AgenticGraspGenSpec + grasp_debug_markers: Out[GraspDebugMarkers] + + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) + self._cached_grasps: PoseArray | None = None + self._cached_grasp_target: str | None = None + self._cached_bbox_center: Vector3 | None = None + self._cached_bbox_size: Vector3 | None = None + + @skill + def scan_objects(self, target_name: str = "object") -> SkillResult[ManipulationSkillError]: + """Register and summarize scene objects matching a target name. + + Args: + target_name: Object prompt/name to register and summarize, e.g. "sphere". + """ + self._scene_registration.set_prompts(text=[target_name]) + objects = self._scene_registration.get_registered_objects() + matches = [obj for obj in objects if obj.name.lower() == target_name.lower()] + if not matches: + return SkillResult[ManipulationSkillError].fail( + "OBJECT_NOT_DETECTED", + f"No registered Grasp target named '{target_name}'.", + ) + + summary = "; ".join( + f"{obj.name}:{obj.object_id} center=({obj.center.x:.3f}, " + f"{obj.center.y:.3f}, {obj.center.z:.3f}) size=({obj.size.x:.3f}, " + f"{obj.size.y:.3f}, {obj.size.z:.3f}) frame={obj.frame_id}" + for obj in matches + ) + return SkillResult[ManipulationSkillError].ok( + f"Registered {len(matches)} Grasp target(s) for '{target_name}': {summary}", + target_count=len(matches), + object_ids=[obj.object_id for obj in matches], + ) + + @skill + def generate_grasps( + self, + target_name: str = "object", + object_id: str | None = None, + filter_collisions: bool = False, + ) -> SkillResult[ManipulationSkillError]: + """Generate and cache grasp candidates for a registered object. + + Args: + target_name: Registered object name to grasp, e.g. "sphere". + object_id: Optional specific registered object id to use instead of target_name. + filter_collisions: Include the scene pointcloud as context when available. + """ + registered_center: Vector3 | None = None + registered_size: Vector3 | None = None + registered_frame: str | None = None + if object_id is not None: + pointcloud = self._scene_registration.get_object_pointcloud_by_object_id(object_id) + target_label = object_id + obj = self._scene_registration.get_object_by_object_id(object_id) + if obj is not None: + registered_center = obj.center + registered_size = obj.size + registered_frame = obj.frame_id + else: + objects = self._scene_registration.get_registered_objects() + matches = [obj for obj in objects if obj.name.lower() == target_name.lower()] + matched_object = matches[0] if matches else None + resolved_object_id = matched_object.object_id if matched_object is not None else None + if matched_object is not None: + registered_center = matched_object.center + registered_size = matched_object.size + registered_frame = matched_object.frame_id + pointcloud = ( + self._scene_registration.get_object_pointcloud_by_object_id(resolved_object_id) + if resolved_object_id is not None + else self._scene_registration.get_object_pointcloud_by_name(target_name) + ) + target_label = resolved_object_id or target_name + object_id = resolved_object_id + if pointcloud is None: + self._cached_grasps = None + self._cached_grasp_target = None + self._cached_bbox_center = None + self._cached_bbox_size = None + return SkillResult[ManipulationSkillError].fail( + "OBJECT_NOT_DETECTED", + f"No pointcloud found for Grasp target '{target_label}'. Run scan_objects first.", + ) + + scene_pointcloud = None + if filter_collisions: + scene_pointcloud = self._scene_registration.get_full_scene_pointcloud( + exclude_object_id=object_id + ) + + pointcloud_centroid = _pointcloud_centroid(pointcloud) + logger.info( + "[GRASP-FRAME] target=%s object_id=%s bbox_frame=%s bbox_center=%s " + "bbox_size=%s bbox_min=%s bbox_max=%s pointcloud_frame=%s " + "pointcloud_centroid=%s point_count=%d", + target_label, + object_id, + registered_frame, + _format_vector(registered_center), + _format_vector(registered_size), + _format_bbox_min(registered_center, registered_size), + _format_bbox_max(registered_center, registered_size), + pointcloud.frame_id, + _format_vector(pointcloud_centroid), + len(pointcloud.points_f32()), + ) + + grasps = self._grasp_gen.generate_grasps(pointcloud, scene_pointcloud) + if grasps is None or len(grasps) == 0: + self._cached_grasps = None + self._cached_grasp_target = None + self._cached_bbox_center = None + self._cached_bbox_size = None + return SkillResult[ManipulationSkillError].fail( + "GRASP_GENERATION_FAILED", + f"No Grasp candidates generated for '{target_label}'.", + ) + + if grasps.header.frame_id != SUPPORTED_GRASP_FRAME: + self._cached_grasps = None + self._cached_grasp_target = None + self._cached_bbox_center = None + self._cached_bbox_size = None + return SkillResult[ManipulationSkillError].fail( + "INVALID_INPUT", + f"Grasp candidates are in frame '{grasps.header.frame_id}', but execute_grasp " + f"requires '{SUPPORTED_GRASP_FRAME}' candidates.", + ) + + first_candidate = grasps[0] + logger.info( + "[GRASP-FRAME] grasps_frame=%s candidate_count=%d first_candidate_pos=%s " + "first_candidate_quat=(%.3f, %.3f, %.3f, %.3f)", + grasps.header.frame_id, + len(grasps), + _format_vector(first_candidate.position), + first_candidate.orientation.x, + first_candidate.orientation.y, + first_candidate.orientation.z, + first_candidate.orientation.w, + ) + logger.info( + "[GRASP-FRAME] candidate_positions_world=%s", + _format_candidate_positions(grasps), + ) + + self._cached_grasps = grasps + self._cached_grasp_target = target_label + self._cached_bbox_center = registered_center + self._cached_bbox_size = registered_size + self._publish_grasp_debug( + GraspDebugMarkers( + frame_id=grasps.header.frame_id, + bbox_center=registered_center, + bbox_size=registered_size, + candidate_poses=list(grasps), + label=f"{target_label} grasp candidates", + ) + ) + return SkillResult[ManipulationSkillError].ok( + f"Generated and cached {len(grasps)} Grasp candidate(s) for '{target_label}'.", + candidate_count=len(grasps), + target=target_label, + pointcloud_frame=pointcloud.frame_id, + pointcloud_centroid=_format_vector(pointcloud_centroid), + first_candidate_position=_format_vector(first_candidate.position), + ) + + @skill + def execute_grasp( + self, candidate_index: int = 0, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: + """Execute a cached grasp candidate without rescanning or regenerating grasps. + + Args: + candidate_index: Index into the candidates cached by generate_grasps(). + robot_name: Robot to move (only needed for multi-arm setups). + """ + if self._cached_grasps is None or len(self._cached_grasps) == 0: + return SkillResult[ManipulationSkillError].fail( + "INVALID_STATE", + "No cached Grasp candidates. Call generate_grasps(...) before execute_grasp(...).", + ) + if candidate_index < 0 or candidate_index >= len(self._cached_grasps): + return SkillResult[ManipulationSkillError].fail( + "INVALID_INPUT", + f"candidate_index {candidate_index} is outside cached range 0..{len(self._cached_grasps) - 1}.", + ) + if self._cached_grasps.header.frame_id != SUPPORTED_GRASP_FRAME: + return SkillResult[ManipulationSkillError].fail( + "INVALID_INPUT", + f"Cached Grasp candidates are in frame '{self._cached_grasps.header.frame_id}', " + f"but execute_grasp requires '{SUPPORTED_GRASP_FRAME}' candidates.", + ) + + selected_index, pose = self._select_feasible_grasp(candidate_index, robot_name) + if pose is None: + return SkillResult[ManipulationSkillError].fail( + "PLANNING_FAILED", + f"No cached Grasp candidates from index {candidate_index} are reachable and collision-free.", + ) + target = self._cached_grasp_target or "unknown target" + self._publish_grasp_debug( + GraspDebugMarkers( + frame_id=self._cached_grasps.header.frame_id, + bbox_center=self._cached_bbox_center, + bbox_size=self._cached_bbox_size, + candidate_poses=list(self._cached_grasps), + selected_candidate_index=selected_index, + pregrasp_pose=_offset_pose_for_approach(pose, DEFAULT_PREGRASP_OFFSET_M), + final_pose=pose, + label=f"selected grasp {selected_index} for {target}", + ) + ) + sequence = self._execute_grasp_sequence(pose, robot_name) + if not sequence.is_success(): + return sequence + return SkillResult[ManipulationSkillError].ok( + f"Executed Grasp candidate {selected_index} for '{target}'.", + candidate_index=candidate_index, + selected_candidate_index=selected_index, + target=target, + ) + + @skill + def move_to_pose( + self, + x: float, + y: float, + z: float, + roll: float | None = None, + pitch: float | None = None, + yaw: float | None = None, + robot_name: str | None = None, + ) -> SkillResult[ManipulationSkillError]: + """Move the robot end-effector to a target pose. + + Args: + x: Target X position in meters. + y: Target Y position in meters. + z: Target Z position in meters. + roll: Target roll in radians (omit to preserve current orientation). + pitch: Target pitch in radians (omit to preserve current orientation). + yaw: Target yaw in radians (omit to preserve current orientation). + robot_name: Robot to move (only needed for multi-arm setups). + """ + return self._manipulation.move_to_pose(x, y, z, roll, pitch, yaw, robot_name) + + @skill + def move_relative( + self, + dx: float, + dy: float, + dz: float, + frame: str = SUPPORTED_RELATIVE_FRAME, + robot_name: str | None = None, + ) -> SkillResult[ManipulationSkillError]: + """Move the robot end-effector by a world-frame Cartesian delta. + + Args: + dx: World-frame X translation in meters. + dy: World-frame Y translation in meters. + dz: World-frame Z translation in meters. + frame: Relative motion frame. Round 1 supports only "world". + robot_name: Robot to move (only needed for multi-arm setups). + """ + if frame != SUPPORTED_RELATIVE_FRAME: + return SkillResult[ManipulationSkillError].fail( + "INVALID_INPUT", + f"Unsupported relative motion frame '{frame}'. Only 'world' is supported.", + ) + current_pose = self._manipulation.get_ee_pose(robot_name) + if current_pose is None: + return SkillResult[ManipulationSkillError].fail( + "NO_PRIOR_POSE", + "Current end-effector pose is unavailable; cannot move relatively.", + ) + return self.move_to_pose( + current_pose.position.x + dx, + current_pose.position.y + dy, + current_pose.position.z + dz, + robot_name=robot_name, + ) + + @skill + def move_along_axis( + self, + axis: str, + distance: float, + frame: str = SUPPORTED_RELATIVE_FRAME, + robot_name: str | None = None, + ) -> SkillResult[ManipulationSkillError]: + """Move the robot end-effector along one world-frame axis. + + Args: + axis: Axis name: "x", "y", or "z". + distance: Signed translation distance in meters. + frame: Relative motion frame. Round 1 supports only "world". + robot_name: Robot to move (only needed for multi-arm setups). + """ + if axis not in {"x", "y", "z"}: + return SkillResult[ManipulationSkillError].fail( + "INVALID_INPUT", + f"Unsupported axis '{axis}'. Expected one of: x, y, z.", + ) + dx = distance if axis == "x" else 0.0 + dy = distance if axis == "y" else 0.0 + dz = distance if axis == "z" else 0.0 + return self.move_relative(dx, dy, dz, frame=frame, robot_name=robot_name) + + @skill + def go_home(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: + """Move the robot to its configured home/observe pose. + + Args: + robot_name: Robot to move (only needed for multi-arm setups). + """ + return self._manipulation.go_home(robot_name) + + @skill + def set_gripper( + self, position: float, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: + """Set the robot gripper opening in meters. + + Args: + position: Gripper opening in meters; 0.0 is closed. + robot_name: Robot to control (only needed for multi-arm setups). + """ + return self._manipulation.set_gripper(position, robot_name) + + def _execute_grasp_sequence( + self, pose: Pose, robot_name: str | None + ) -> SkillResult[ManipulationSkillError]: + pregrasp_pose = _offset_pose_for_approach(pose, DEFAULT_PREGRASP_OFFSET_M) + rpy = pose.orientation.to_euler() + logger.info( + "[GRASP-FRAME] execute_targets_world pregrasp=%s final=%s final_rpy=(%.3f, %.3f, %.3f)", + _format_vector(pregrasp_pose.position), + _format_vector(pose.position), + rpy.x, + rpy.y, + rpy.z, + ) + + step = self.open_gripper(robot_name) + if not step.is_success(): + return step + step = self.move_to_pose( + pregrasp_pose.position.x, + pregrasp_pose.position.y, + pregrasp_pose.position.z, + rpy.x, + rpy.y, + rpy.z, + robot_name, + ) + if not step.is_success(): + return step + step = self.move_to_pose( + pose.position.x, + pose.position.y, + pose.position.z, + rpy.x, + rpy.y, + rpy.z, + robot_name, + ) + if not step.is_success(): + return step + actual_pose = self._manipulation.get_ee_pose(robot_name) + logger.info( + "[GRASP-FRAME] final_target=%s actual_ee_after_final=%s", + _format_vector(pose.position), + _format_vector(actual_pose.position if actual_pose is not None else None), + ) + step = self.close_gripper(robot_name) + if not step.is_success(): + return step + step = self.move_to_pose( + pregrasp_pose.position.x, + pregrasp_pose.position.y, + pregrasp_pose.position.z, + rpy.x, + rpy.y, + rpy.z, + robot_name, + ) + if not step.is_success(): + return step + step = self.move_relative(0.0, 0.0, DEFAULT_LIFT_DISTANCE_M, robot_name=robot_name) + if not step.is_success(): + return step + return SkillResult[ManipulationSkillError].ok("Grasp execution sequence completed.") + + def _select_feasible_grasp( + self, start_index: int, robot_name: str | None + ) -> tuple[int, Pose | None]: + assert self._cached_grasps is not None + for candidate_index in range(start_index, len(self._cached_grasps)): + pose = self._cached_grasps[candidate_index] + if self._is_grasp_candidate_feasible(candidate_index, pose, robot_name): + return candidate_index, pose + return start_index, None + + def _is_grasp_candidate_feasible( + self, candidate_index: int, pose: Pose, robot_name: str | None + ) -> bool: + pregrasp_pose = _offset_pose_for_approach(pose, DEFAULT_PREGRASP_OFFSET_M) + logger.info( + "[GRASP-FRAME] feasibility_targets_world candidate_index=%d pregrasp=%s final=%s", + candidate_index, + _format_vector(pregrasp_pose.position), + _format_vector(pose.position), + ) + if not self._manipulation.plan_to_pose(pregrasp_pose, robot_name): + logger.info( + "[GRASP-FRAME] candidate_index=%d rejected: pregrasp is not plan-feasible", + candidate_index, + ) + self._manipulation.reset() + return False + if not self._manipulation.plan_to_pose(pose, robot_name): + logger.info( + "[GRASP-FRAME] candidate_index=%d rejected: grasp pose is not plan-feasible", + candidate_index, + ) + self._manipulation.reset() + return False + logger.info("[GRASP-FRAME] candidate_index=%d selected as plan-feasible", candidate_index) + return True + + def _publish_grasp_debug(self, markers: GraspDebugMarkers) -> None: + debug_out = getattr(self, "grasp_debug_markers", None) + if debug_out is not None: + debug_out.publish(markers) + + +def _offset_pose_for_approach(pose: Pose, distance: float) -> Pose: + """Offset away from the GPD candidate along candidate local -X.""" + approach_offset = pose.orientation.rotate_vector(Vector3(-distance, 0.0, 0.0)) + return Pose((pose.position + approach_offset, pose.orientation)) + + +def _pointcloud_centroid(pointcloud: PointCloud2) -> Vector3 | None: + points = pointcloud.points_f32() + if len(points) == 0: + return None + centroid = np.mean(points, axis=0) + return Vector3(float(centroid[0]), float(centroid[1]), float(centroid[2])) + + +def _format_vector(vector: Vector3 | None) -> str: + if vector is None: + return "None" + return f"({vector.x:.3f}, {vector.y:.3f}, {vector.z:.3f})" + + +def _format_bbox_min(center: Vector3 | None, size: Vector3 | None) -> str: + if center is None or size is None: + return "None" + return _format_vector(center - size * 0.5) + + +def _format_bbox_max(center: Vector3 | None, size: Vector3 | None) -> str: + if center is None or size is None: + return "None" + return _format_vector(center + size * 0.5) + + +def _format_candidate_positions(grasps: PoseArray) -> str: + return ", ".join( + f"{index}:{_format_vector(pose.position)}" for index, pose in enumerate(grasps) + ) + + agentic_manipulation = AgenticManipulationModule.blueprint +agentic_grasp_manipulation = AgenticGraspManipulationModule.blueprint diff --git a/dimos/manipulation/agentic_manipulation_spec.py b/dimos/manipulation/agentic_manipulation_spec.py index 2b4c929869..2b87982d52 100644 --- a/dimos/manipulation/agentic_manipulation_spec.py +++ b/dimos/manipulation/agentic_manipulation_spec.py @@ -20,6 +20,9 @@ from dimos.agents.skill_result import SkillResult from dimos.manipulation.skill_errors import ManipulationSkillError +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseArray import PoseArray +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 from dimos.spec.utils import Spec @@ -38,3 +41,28 @@ def open_gripper( def close_gripper( self, robot_name: str | None = None ) -> SkillResult[ManipulationSkillError]: ... + def reset(self) -> SkillResult[ManipulationSkillError]: ... + def set_gripper( + self, position: float, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: ... + def get_ee_pose(self, robot_name: str | None = None) -> Pose | None: ... + def plan_to_pose(self, pose: Pose, robot_name: str | None = None) -> bool: ... + def move_to_pose( + self, + x: float, + y: float, + z: float, + roll: float | None = None, + pitch: float | None = None, + yaw: float | None = None, + robot_name: str | None = None, + ) -> SkillResult[ManipulationSkillError]: ... + def go_home(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: ... + + +class AgenticGraspGenSpec(Spec, Protocol): + def generate_grasps( + self, + pointcloud: PointCloud2, + scene_pointcloud: PointCloud2 | None = None, + ) -> PoseArray | None: ... diff --git a/dimos/manipulation/grasping/manual_agentic_gpd_grasp_demo.py b/dimos/manipulation/grasping/manual_agentic_gpd_grasp_demo.py new file mode 100644 index 0000000000..d260cbed27 --- /dev/null +++ b/dimos/manipulation/grasping/manual_agentic_gpd_grasp_demo.py @@ -0,0 +1,140 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Manual RPC driver for the agent-facing GPD MuJoCo grasp demo.""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable, Sequence +import time +from typing import Protocol, cast + +from dimos.agents.skill_result import SkillResult +from dimos.core.rpc_client import RPCClient +from dimos.manipulation.agentic_manipulation_module import AgenticGraspManipulationModule +from dimos.manipulation.skill_errors import ManipulationSkillError + + +class AgenticGraspToolClient(Protocol): + def scan_objects(self, target_name: str = "object") -> SkillResult[ManipulationSkillError]: ... + def generate_grasps( + self, + target_name: str = "object", + object_id: str | None = None, + filter_collisions: bool = False, + ) -> SkillResult[ManipulationSkillError]: ... + def execute_grasp( + self, + candidate_index: int = 0, + robot_name: str | None = None, + ) -> SkillResult[ManipulationSkillError]: ... + def stop_rpc_client(self) -> None: ... + + +class ManualAgenticGpdGraspDemoError(RuntimeError): + """Raised when a manual GPD grasp demo step fails.""" + + +def run_manual_agentic_gpd_grasp_sequence( + target_name: str = "sphere", + candidate_index: int = 0, + robot_name: str | None = None, + scan_attempts: int = 90, + retry_interval_s: float = 0.5, + client: AgenticGraspToolClient | None = None, +) -> Sequence[SkillResult[ManipulationSkillError]]: + """Run scan -> generate -> execute against a running manual agentic GPD demo. + + Start `manual-agentic-gpd-mujoco-grasp-demo` in another process before calling this helper. + The helper uses the agent-facing tool surface directly over RPC and does not require + `McpClient`, an LLM, or an API key. + """ + owns_client = client is None + tool_client = client or cast( + "AgenticGraspToolClient", + RPCClient.remote(AgenticGraspManipulationModule), + ) + try: + scan = _retry_skill( + lambda: tool_client.scan_objects(target_name), + attempts=scan_attempts, + retry_interval_s=retry_interval_s, + ) + _raise_if_failed("scan_objects", scan) + + generate = tool_client.generate_grasps(target_name) + _raise_if_failed("generate_grasps", generate) + if int(generate.metadata.get("candidate_count", 0)) < 1: + raise ManualAgenticGpdGraspDemoError( + "generate_grasps succeeded without reporting cached candidates." + ) + + execute = tool_client.execute_grasp(candidate_index, robot_name) + _raise_if_failed("execute_grasp", execute) + return [scan, generate, execute] + finally: + if owns_client: + tool_client.stop_rpc_client() + + +def _retry_skill( + call: Callable[[], SkillResult[ManipulationSkillError]], + attempts: int, + retry_interval_s: float, +) -> SkillResult[ManipulationSkillError]: + last_result: SkillResult[ManipulationSkillError] | None = None + for _ in range(max(1, attempts)): + result = call() + if result.is_success(): + return result + last_result = result + time.sleep(max(0.0, retry_interval_s)) + if last_result is None: + raise ManualAgenticGpdGraspDemoError("No scan attempt was made.") + return last_result + + +def _raise_if_failed(step_name: str, result: SkillResult[ManipulationSkillError]) -> None: + if result.is_success(): + return + raise ManualAgenticGpdGraspDemoError( + f"{step_name} failed: {result.error_code or 'UNKNOWN'}: {result.message}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run scan_objects -> generate_grasps -> execute_grasp over RPC." + ) + parser.add_argument("--target-name", default="sphere") + parser.add_argument("--candidate-index", type=int, default=0) + parser.add_argument("--robot-name", default=None) + parser.add_argument("--scan-attempts", type=int, default=90) + parser.add_argument("--retry-interval-s", type=float, default=0.5) + args = parser.parse_args() + + results = run_manual_agentic_gpd_grasp_sequence( + target_name=args.target_name, + candidate_index=args.candidate_index, + robot_name=args.robot_name, + scan_attempts=args.scan_attempts, + retry_interval_s=args.retry_interval_s, + ) + for result in results: + print(result.message) + + +if __name__ == "__main__": + main() diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index b4ad4d4b6c..5df6f24c29 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -26,6 +26,7 @@ from collections.abc import Callable, Mapping, Sequence from enum import Enum +import os import threading import time import traceback @@ -120,6 +121,20 @@ logger = setup_logger() +FRAME_DIAGNOSTICS_ENV = "DIMOS_MANIPULATION_FRAME_DIAGNOSTICS" + + +def _format_pose_stamped(pose: PoseStamped | None) -> str: + if pose is None: + return "None" + return ( + f"frame={pose.frame_id} " + f"pos=({pose.position.x:.3f}, {pose.position.y:.3f}, {pose.position.z:.3f}) " + f"quat=({pose.orientation.x:.3f}, {pose.orientation.y:.3f}, " + f"{pose.orientation.z:.3f}, {pose.orientation.w:.3f})" + ) + + # Composite type aliases for readability (using semantic IDs from planning.spec) RobotEntry: TypeAlias = tuple[WorldRobotID, RobotModelConfig, JointTrajectoryGenerator] """(world_robot_id, config, trajectory_generator)""" @@ -165,6 +180,9 @@ class ManipulationModuleConfig(ModuleConfig): trajectory_parametrization: TrajectoryParametrizationConfig = Field( default_factory=TrajectoryParametrizationConfig ) + execution_settle_timeout: float = 7.0 + execution_joint_tolerance: float = 0.04 + execution_poll_interval: float = 0.1 class ManipulationModule(Module): @@ -544,6 +562,48 @@ def get_ee_pose(self, robot_name: RobotName | None = None) -> Pose | None: return self._world_monitor.get_link_pose(robot_id, config.end_effector_link) return None + def _frame_diagnostics_enabled(self) -> bool: + return os.environ.get(FRAME_DIAGNOSTICS_ENV) == "1" + + def _log_robot_frame_diagnostics(self, label: str, robot_name: RobotName | None = None) -> None: + """Log planning-group and link poses for frame debugging.""" + if not self._frame_diagnostics_enabled(): + return + if (robot := self._get_robot(robot_name)) is None or self._world_monitor is None: + logger.info("[FRAME-DIAG] %s robot unavailable", label) + return + + selected_robot_name, robot_id, config, _ = robot + group_id = self._primary_pose_group_id_for_robot(config.name) + group = self._world_monitor.planning_groups.get(group_id) if group_id is not None else None + group_pose = ( + self._world_monitor.get_group_ee_pose(group_id, joint_state=None) + if group_id is not None + else None + ) + ee_link_pose = ( + self._world_monitor.get_link_pose(robot_id, config.end_effector_link) + if config.end_effector_link is not None + else None + ) + link7_pose = self._world_monitor.get_link_pose(robot_id, "link7") + gripper_base_pose = self._world_monitor.get_link_pose(robot_id, "xarm_gripper_base_link") + + logger.info( + "[FRAME-DIAG] %s robot=%s group=%s group_tip=%s config_ee=%s base=%s " + "group_pose=%s ee_link_pose=%s link7_pose=%s gripper_base_pose=%s", + label, + selected_robot_name, + group_id, + group.tip_link if group is not None else None, + config.end_effector_link, + config.base_link, + _format_pose_stamped(group_pose), + _format_pose_stamped(ee_link_pose), + _format_pose_stamped(link7_pose), + _format_pose_stamped(gripper_base_pose), + ) + @rpc def is_collision_free(self, joints: list[float], robot_name: RobotName | None = None) -> bool: """Check if joint configuration is collision-free. @@ -859,6 +919,52 @@ def _plan_selected_path( group_ids, path_joints, ) + if self._frame_diagnostics_enabled() and result.path: + final_joints = result.path[-1] + for group_id in group_ids: + group = self._world_monitor.planning_groups.get(group_id) + final_by_name = dict(zip(final_joints.name, final_joints.position, strict=True)) + local_positions: list[float] = [] + missing_names: list[str] = [] + for local_name, global_name in zip( + group.local_joint_names, + group.joint_names, + strict=True, + ): + if local_name in final_by_name: + local_positions.append(float(final_by_name[local_name])) + elif global_name in final_by_name: + local_positions.append(float(final_by_name[global_name])) + else: + missing_names.append(global_name) + if missing_names: + logger.info( + "[FRAME-DIAG] planned_final_fk group=%s missing joints=%s", + group_id, + missing_names, + ) + continue + local_final_joints = JointState( + name=list(group.local_joint_names), + position=local_positions, + ) + try: + final_pose = self._world_monitor.get_group_ee_pose( + group_id, joint_state=local_final_joints + ) + except Exception as exc: + logger.info( + "[FRAME-DIAG] planned_final_fk group=%s unavailable: %s", + group_id, + exc, + ) + continue + logger.info( + "[FRAME-DIAG] planned_final_fk group=%s group_tip=%s pose=%s", + group_id, + group.tip_link, + _format_pose_stamped(final_pose), + ) self._store_generated_plan(group_ids, result) assert self._last_plan is not None trajectory = self._parametrize_plan(self._last_plan) @@ -1883,6 +1989,117 @@ def _wait_for_trajectory_completion( logger.warning(f"Trajectory execution timed out after {timeout}s") return False + def _final_trajectory_target_for_robot( + self, robot_name: RobotName | None = None + ) -> JointState | None: + """Return the final local joint target for the latest generated trajectory.""" + trajectory = self._last_trajectory + if trajectory is None or not trajectory.points or not trajectory.joint_names: + return None + if (robot := self._get_robot(robot_name)) is None or self._world_monitor is None: + return None + + rname, robot_id, config, _ = robot + final_point = trajectory.points[-1] + if len(final_point.positions) != len(trajectory.joint_names): + return None + + target_indices = { + joint_name: index for index, joint_name in enumerate(trajectory.joint_names) + } + current = self._world_monitor.get_current_joint_state(robot_id) + current_by_name = ( + dict(zip(current.name, current.position, strict=True)) if current is not None else {} + ) + global_joint_names = make_global_joint_names(rname, config.joint_names) + target_positions: list[float] = [] + for local_name, global_joint_name in zip( + config.joint_names, global_joint_names, strict=True + ): + target_index = target_indices.get(global_joint_name) + if target_index is not None: + target_positions.append(float(final_point.positions[target_index])) + elif local_name in current_by_name: + target_positions.append(float(current_by_name[local_name])) + else: + return None + return JointState(name=list(config.joint_names), position=target_positions) + + def _wait_for_execution_convergence( + self, + robot_name: RobotName | None = None, + timeout: float | None = None, + joint_tolerance: float | None = None, + poll_interval: float | None = None, + ) -> bool: + """Wait until live robot joints converge to the latest trajectory endpoint.""" + if robot_name is None and self._last_plan is not None and self._last_plan.path: + try: + robot_names = self._affected_robot_names(self._last_plan) + except Exception as exc: + logger.warning( + "Failed to resolve generated plan while waiting for convergence: %s", exc + ) + return False + return all( + self._wait_for_execution_convergence( + name, + timeout=timeout, + joint_tolerance=joint_tolerance, + poll_interval=poll_interval, + ) + for name in robot_names + ) + + if (robot := self._get_robot(robot_name)) is None or self._world_monitor is None: + return True + rname, robot_id, _, _ = robot + target = self._final_trajectory_target_for_robot(rname) + if target is None: + logger.info("No trajectory endpoint available for convergence wait on '%s'", rname) + return True + + wait_timeout = self.config.execution_settle_timeout if timeout is None else timeout + tolerance = ( + self.config.execution_joint_tolerance if joint_tolerance is None else joint_tolerance + ) + interval = self.config.execution_poll_interval if poll_interval is None else poll_interval + if wait_timeout <= 0.0: + return True + + target_by_name = dict(zip(target.name, target.position, strict=True)) + start = time.monotonic() + last_max_error = float("inf") + while (time.monotonic() - start) < wait_timeout: + current = self._world_monitor.get_current_joint_state(robot_id) + if current is not None and len(current.name) == len(current.position): + current_by_name = dict(zip(current.name, current.position, strict=True)) + errors = [ + abs(float(current_by_name[name]) - float(position)) + for name, position in target_by_name.items() + if name in current_by_name + ] + if len(errors) == len(target_by_name): + last_max_error = max(errors, default=0.0) + if last_max_error <= tolerance: + logger.info( + "Trajectory converged for '%s': max_joint_error=%.4f rad", + rname, + last_max_error, + ) + return True + time.sleep(interval) + + logger.warning( + "Trajectory did not converge for '%s' within %.1fs: max_joint_error=%.4f rad " + "(tolerance=%.4f rad)", + rname, + wait_timeout, + last_max_error, + tolerance, + ) + return False + def _lift_if_low( self, robot_name: RobotName | None = None, min_z: float = 0.05 ) -> SkillResult[ManipulationSkillError]: @@ -1921,6 +2138,12 @@ def _preview_execute_wait( if not self._wait_for_trajectory_completion(robot_name): return SkillResult.fail("EXECUTION_TIMEOUT", "Trajectory execution timed out") + if not self._wait_for_execution_convergence(robot_name): + return SkillResult.fail( + "EXECUTION_TIMEOUT", + "Trajectory execution completed but robot did not converge to the final target", + ) + return SkillResult.ok() @skill @@ -2006,11 +2229,15 @@ def move_to_pose( pose = Pose(Vector3(x, y, z), orientation) + self._log_robot_frame_diagnostics("move_to_pose_before_lift", robot_name) + # If EE is low, lift up first to clear obstacles lift = self._lift_if_low(robot_name) if not lift.is_success(): return lift + self._log_robot_frame_diagnostics("move_to_pose_after_lift", robot_name) + if not self.plan_to_pose(pose, robot_name): return SkillResult.fail( "PLANNING_FAILED", @@ -2021,6 +2248,8 @@ def move_to_pose( if not exec_result.is_success(): return exec_result + self._log_robot_frame_diagnostics("move_to_pose_after_execute", robot_name) + return SkillResult.ok(f"Reached target pose ({x:.3f}, {y:.3f}, {z:.3f})") @skill diff --git a/dimos/manipulation/test_agentic_manipulation_module.py b/dimos/manipulation/test_agentic_manipulation_module.py index 5670d247aa..66d5453332 100644 --- a/dimos/manipulation/test_agentic_manipulation_module.py +++ b/dimos/manipulation/test_agentic_manipulation_module.py @@ -17,15 +17,28 @@ from collections.abc import Callable, Iterator import inspect import json +import math from typing import TypeAlias, cast, get_type_hints import pytest from dimos.agents.skill_result import SkillResult -from dimos.manipulation.agentic_manipulation_module import AgenticManipulationModule +from dimos.manipulation.agentic_manipulation_module import ( + DEFAULT_LIFT_DISTANCE_M, + DEFAULT_PREGRASP_OFFSET_M, + AgenticGraspManipulationModule, + AgenticManipulationModule, +) from dimos.manipulation.skill_errors import ManipulationSkillError - -Call: TypeAlias = tuple[str, tuple[str | float | None, ...]] +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseArray import PoseArray +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.msgs.std_msgs.Header import Header + +Call: TypeAlias = tuple[str, tuple[object, ...]] GetStateMethod: TypeAlias = Callable[ [AgenticManipulationModule, str | None], SkillResult[ManipulationSkillError] ] @@ -40,10 +53,19 @@ ] +def make_pose(x: float, y: float, z: float) -> Pose: + return Pose(([x, y, z], Quaternion([0.0, 0.0, 0.0, 1.0]))) + + +def make_oriented_pose(x: float, y: float, z: float, orientation: Quaternion) -> Pose: + return Pose(([x, y, z], orientation)) + + class FakeManipulationProvider: def __init__(self, result: SkillResult[ManipulationSkillError]) -> None: self.result = result self.calls: list[Call] = [] + self.plan_results: list[bool] = [] def get_robot_state(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: self.calls.append(("get_robot_state", (robot_name,))) @@ -71,6 +93,104 @@ def close_gripper(self, robot_name: str | None = None) -> SkillResult[Manipulati self.calls.append(("close_gripper", (robot_name,))) return self.result + def reset(self) -> SkillResult[ManipulationSkillError]: + self.calls.append(("reset", ())) + return self.result + + def move_to_pose( + self, + x: float, + y: float, + z: float, + roll: float | None = None, + pitch: float | None = None, + yaw: float | None = None, + robot_name: str | None = None, + ) -> SkillResult[ManipulationSkillError]: + self.calls.append(("move_to_pose", (x, y, z, roll, pitch, yaw, robot_name))) + return self.result + + def get_ee_pose(self, robot_name: str | None = None) -> Pose | None: + self.calls.append(("get_ee_pose", (robot_name,))) + return make_pose(1.0, 2.0, 3.0) + + def plan_to_pose(self, pose: Pose, robot_name: str | None = None) -> bool: + self.calls.append(("plan_to_pose", (pose, robot_name))) + if self.plan_results: + return self.plan_results.pop(0) + return True + + def go_home(self, robot_name: str | None = None) -> SkillResult[ManipulationSkillError]: + self.calls.append(("go_home", (robot_name,))) + return self.result + + def set_gripper( + self, position: float, robot_name: str | None = None + ) -> SkillResult[ManipulationSkillError]: + self.calls.append(("set_gripper", (position, robot_name))) + return self.result + + +class FakeSceneRegistration: + def __init__(self) -> None: + self.calls: list[Call] = [] + self.objects = [ + RegisteredObject( + object_id="obj-1", + name="sphere", + center=Vector3(0.1, 0.2, 0.3), + size=Vector3(0.4, 0.5, 0.6), + frame_id="world", + ts=1.0, + ) + ] + self.name_pointcloud: PointCloud2 | None = PointCloud2(frame_id="world", ts=1.0) + self.id_pointcloud: PointCloud2 | None = PointCloud2(frame_id="world", ts=2.0) + self.scene_pointcloud: PointCloud2 | None = PointCloud2(frame_id="world", ts=3.0) + + def set_prompts(self, text: list[str] | None = None, bboxes: object | None = None) -> None: + del bboxes + prompt_text = [] if text is None else text + self.calls.append(("set_prompts", (tuple(prompt_text),))) + + def get_registered_objects(self) -> list[RegisteredObject]: + self.calls.append(("get_registered_objects", ())) + return self.objects + + def get_object_pointcloud_by_name(self, name: str) -> PointCloud2 | None: + self.calls.append(("get_object_pointcloud_by_name", (name,))) + return self.name_pointcloud + + def get_object_pointcloud_by_object_id(self, object_id: str) -> PointCloud2 | None: + self.calls.append(("get_object_pointcloud_by_object_id", (object_id,))) + return self.id_pointcloud + + def get_object_by_object_id(self, object_id: str) -> RegisteredObject | None: + self.calls.append(("get_object_by_object_id", (object_id,))) + return next((obj for obj in self.objects if obj.object_id == object_id), None) + + def get_full_scene_pointcloud( + self, + exclude_object_id: str | None = None, + depth_trunc: float = 2, + voxel_size: float = 0.01, + ) -> PointCloud2 | None: + del depth_trunc, voxel_size + self.calls.append(("get_full_scene_pointcloud", (exclude_object_id,))) + return self.scene_pointcloud + + +class FakeGraspGen: + def __init__(self, grasps: PoseArray) -> None: + self.calls: list[tuple[str, tuple[PointCloud2 | None, PointCloud2 | None]]] = [] + self.grasps = grasps + + def generate_grasps( + self, pointcloud: PointCloud2, scene_pointcloud: PointCloud2 | None = None + ) -> PoseArray: + self.calls.append(("generate_grasps", (pointcloud, scene_pointcloud))) + return self.grasps + @pytest.fixture def skill_result() -> SkillResult[ManipulationSkillError]: @@ -246,3 +366,337 @@ def test_skill_methods_generate_get_skills_schemas(module: AgenticManipulationMo speed_schema = json.loads(skills["set_motion_speed"].args_schema) assert "speed_scale" in speed_schema["properties"] assert speed_schema["required"] == ["speed_scale"] + + +@pytest.fixture +def grasp_candidates() -> PoseArray: + return PoseArray( + header=Header("world"), + poses=[make_pose(0.4, 0.5, 0.6), make_pose(0.7, 0.8, 0.9)], + ) + + +@pytest.fixture +def scene_registration() -> FakeSceneRegistration: + return FakeSceneRegistration() + + +@pytest.fixture +def grasp_gen(grasp_candidates: PoseArray) -> FakeGraspGen: + return FakeGraspGen(grasp_candidates) + + +@pytest.fixture +def grasp_module( + provider: FakeManipulationProvider, + scene_registration: FakeSceneRegistration, + grasp_gen: FakeGraspGen, +) -> Iterator[AgenticGraspManipulationModule]: + agentic_module = AgenticGraspManipulationModule() + agentic_module._manipulation = provider + agentic_module._scene_registration = scene_registration + agentic_module._grasp_gen = grasp_gen + try: + yield agentic_module + finally: + agentic_module.stop() + + +def test_grasp_skill_methods_generate_expected_schemas( + grasp_module: AgenticGraspManipulationModule, +) -> None: + skills = {skill.func_name: skill for skill in grasp_module.get_skills()} + + assert set(skills) == { + "close_gripper", + "execute_grasp", + "generate_grasps", + "get_motion_speed", + "get_robot_state", + "go_home", + "move_along_axis", + "move_relative", + "move_to_joints", + "move_to_pose", + "open_gripper", + "scan_objects", + "set_gripper", + "set_motion_speed", + } + expected_required = { + "execute_grasp": [], + "generate_grasps": [], + "go_home": [], + "move_along_axis": ["axis", "distance"], + "move_relative": ["dx", "dy", "dz"], + "move_to_pose": ["x", "y", "z"], + "scan_objects": [], + "set_gripper": ["position"], + } + for method_name, required in expected_required.items(): + schema = json.loads(skills[method_name].args_schema) + assert schema["type"] == "object" + assert schema.get("required", []) == required + + +def test_grasp_facade_delegates_scan_generate_motion_and_gripper( + grasp_module: AgenticGraspManipulationModule, + provider: FakeManipulationProvider, + scene_registration: FakeSceneRegistration, + grasp_gen: FakeGraspGen, + skill_result: SkillResult[ManipulationSkillError], +) -> None: + scan_result = grasp_module.scan_objects("sphere") + generate_result = grasp_module.generate_grasps("sphere", filter_collisions=True) + move_result = grasp_module.move_to_pose(1.0, 2.0, 3.0, 0.1, 0.2, 0.3, "arm") + home_result = grasp_module.go_home("arm") + gripper_result = grasp_module.set_gripper(0.04, "arm") + + assert scan_result.success is True + assert generate_result.success is True + assert move_result.message == skill_result.message + assert home_result.message == skill_result.message + assert gripper_result.message == skill_result.message + assert scene_registration.calls == [ + ("set_prompts", (("sphere",),)), + ("get_registered_objects", ()), + ("get_registered_objects", ()), + ("get_object_pointcloud_by_object_id", ("obj-1",)), + ("get_full_scene_pointcloud", ("obj-1",)), + ] + assert grasp_gen.calls == [ + ( + "generate_grasps", + (scene_registration.id_pointcloud, scene_registration.scene_pointcloud), + ) + ] + assert provider.calls == [ + ("move_to_pose", (1.0, 2.0, 3.0, 0.1, 0.2, 0.3, "arm")), + ("go_home", ("arm",)), + ("set_gripper", (0.04, "arm")), + ] + + +def test_execute_grasp_uses_cached_candidate_without_regenerate( + grasp_module: AgenticGraspManipulationModule, + provider: FakeManipulationProvider, + scene_registration: FakeSceneRegistration, + grasp_gen: FakeGraspGen, +) -> None: + generate_result = grasp_module.generate_grasps("sphere") + scene_registration.calls.clear() + grasp_gen.calls.clear() + + result = grasp_module.execute_grasp(1, "arm") + + assert generate_result.success is True + assert result.success is True + assert scene_registration.calls == [] + assert grasp_gen.calls == [] + assert provider.calls == [ + ( + "plan_to_pose", + (make_pose(0.7 - DEFAULT_PREGRASP_OFFSET_M, 0.8, 0.9), "arm"), + ), + ("plan_to_pose", (make_pose(0.7, 0.8, 0.9), "arm")), + ("open_gripper", ("arm",)), + ( + "move_to_pose", + ( + 0.7 - DEFAULT_PREGRASP_OFFSET_M, + 0.8, + 0.9, + 0.0, + 0.0, + 0.0, + "arm", + ), + ), + ( + "move_to_pose", + (0.7, 0.8, 0.9, 0.0, 0.0, 0.0, "arm"), + ), + ("get_ee_pose", ("arm",)), + ("close_gripper", ("arm",)), + ( + "move_to_pose", + ( + 0.7 - DEFAULT_PREGRASP_OFFSET_M, + 0.8, + 0.9, + 0.0, + 0.0, + 0.0, + "arm", + ), + ), + ("get_ee_pose", ("arm",)), + ("move_to_pose", (1.0, 2.0, 3.0 + DEFAULT_LIFT_DISTANCE_M, None, None, None, "arm")), + ] + + +def test_execute_grasp_approaches_rotated_candidate_along_local_axis( + grasp_module: AgenticGraspManipulationModule, + grasp_gen: FakeGraspGen, + provider: FakeManipulationProvider, +) -> None: + grasp_gen.grasps = PoseArray( + header=Header("world"), + poses=[ + make_oriented_pose( + 0.4, + 0.5, + 0.6, + Quaternion.from_euler(Vector3(0.0, 0.0, math.pi / 2)), + ) + ], + ) + generate_result = grasp_module.generate_grasps("sphere") + + result = grasp_module.execute_grasp(0, "arm") + + assert generate_result.success is True + assert result.success is True + pregrasp_args = provider.calls[3][1] + final_args = provider.calls[4][1] + assert pregrasp_args[:3] == pytest.approx((0.4, 0.5 - DEFAULT_PREGRASP_OFFSET_M, 0.6)) + assert final_args[:3] == pytest.approx((0.4, 0.5, 0.6)) + + +def test_execute_grasp_selects_first_plan_feasible_cached_candidate( + grasp_module: AgenticGraspManipulationModule, + provider: FakeManipulationProvider, +) -> None: + grasp_module._cached_grasps = PoseArray( + header=Header("world"), + poses=[make_pose(0.1, 0.2, 0.3), make_pose(0.7, 0.8, 0.9)], + ) + provider.plan_results = [False, True, True] + + result = grasp_module.execute_grasp(0, "arm") + + assert result.success is True + assert result.metadata["selected_candidate_index"] == 1 + call_names = [call[0] for call in provider.calls] + assert call_names[:4] == ["plan_to_pose", "reset", "plan_to_pose", "plan_to_pose"] + assert provider.calls[0] == ( + "plan_to_pose", + (make_pose(0.1 - DEFAULT_PREGRASP_OFFSET_M, 0.2, 0.3), "arm"), + ) + assert provider.calls[1] == ("reset", ()) + assert provider.calls[2] == ( + "plan_to_pose", + (make_pose(0.7 - DEFAULT_PREGRASP_OFFSET_M, 0.8, 0.9), "arm"), + ) + assert provider.calls[3] == ("plan_to_pose", (make_pose(0.7, 0.8, 0.9), "arm")) + assert ("move_to_pose", (0.7, 0.8, 0.9, 0.0, 0.0, 0.0, "arm")) in provider.calls + + +def test_execute_grasp_fails_without_motion_when_no_cached_candidate_is_feasible( + grasp_module: AgenticGraspManipulationModule, + provider: FakeManipulationProvider, +) -> None: + grasp_module._cached_grasps = PoseArray( + header=Header("world"), + poses=[make_pose(0.1, 0.2, 0.3), make_pose(0.7, 0.8, 0.9)], + ) + provider.plan_results = [False, False] + + result = grasp_module.execute_grasp(0, "arm") + + assert result.success is False + assert result.error_code == "PLANNING_FAILED" + assert "No cached Grasp candidates" in result.message + assert provider.calls == [ + ( + "plan_to_pose", + (make_pose(0.1 - DEFAULT_PREGRASP_OFFSET_M, 0.2, 0.3), "arm"), + ), + ("reset", ()), + ( + "plan_to_pose", + (make_pose(0.7 - DEFAULT_PREGRASP_OFFSET_M, 0.8, 0.9), "arm"), + ), + ("reset", ()), + ] + + +def test_generate_grasps_rejects_non_world_candidates_before_caching( + grasp_module: AgenticGraspManipulationModule, + grasp_gen: FakeGraspGen, + provider: FakeManipulationProvider, +) -> None: + grasp_gen.grasps = PoseArray( + header=Header("wrist_camera_color_optical_frame"), + poses=[make_pose(0.1, 0.2, 0.3)], + ) + + generate_result = grasp_module.generate_grasps("sphere") + execute_result = grasp_module.execute_grasp() + + assert generate_result.success is False + assert generate_result.error_code == "INVALID_INPUT" + assert "requires 'world' candidates" in generate_result.message + assert execute_result.error_code == "INVALID_STATE" + assert provider.calls == [] + + +def test_execute_grasp_rejects_cached_non_world_candidates_before_motion( + grasp_module: AgenticGraspManipulationModule, + provider: FakeManipulationProvider, +) -> None: + grasp_module._cached_grasps = PoseArray( + header=Header("wrist_camera_color_optical_frame"), + poses=[make_pose(0.1, 0.2, 0.3)], + ) + + result = grasp_module.execute_grasp() + + assert result.success is False + assert result.error_code == "INVALID_INPUT" + assert "requires 'world' candidates" in result.message + assert provider.calls == [] + + +def test_execute_grasp_missing_cache_fails_without_scan_or_generate( + grasp_module: AgenticGraspManipulationModule, + provider: FakeManipulationProvider, + scene_registration: FakeSceneRegistration, + grasp_gen: FakeGraspGen, +) -> None: + result = grasp_module.execute_grasp() + + assert result.success is False + assert result.error_code == "INVALID_STATE" + assert provider.calls == [] + assert scene_registration.calls == [] + assert grasp_gen.calls == [] + + +def test_execute_grasp_invalid_index_fails_before_motion( + grasp_module: AgenticGraspManipulationModule, + provider: FakeManipulationProvider, +) -> None: + grasp_module._cached_grasps = PoseArray(poses=[make_pose(0.1, 0.2, 0.3)]) + + result = grasp_module.execute_grasp(3) + + assert result.success is False + assert result.error_code == "INVALID_INPUT" + assert provider.calls == [] + + +def test_move_relative_defaults_to_world_frame_and_rejects_unsupported_frame( + grasp_module: AgenticGraspManipulationModule, + provider: FakeManipulationProvider, +) -> None: + world_result = grasp_module.move_relative(0.1, -0.2, 0.3, robot_name="arm") + bad_frame_result = grasp_module.move_relative(0.1, 0.0, 0.0, frame="tool") + + assert world_result.success is True + assert bad_frame_result.success is False + assert bad_frame_result.error_code == "INVALID_INPUT" + assert provider.calls == [ + ("get_ee_pose", ("arm",)), + ("move_to_pose", (1.1, 1.8, 3.3, None, None, None, "arm")), + ] diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 0236430329..20f3773136 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -698,6 +698,54 @@ def test_execute_times_out_when_coordinator_rpc_does_not_respond( mock_client.rpc.call_sync.assert_called_once() mock_client.task_invoke.assert_not_called() + def test_final_trajectory_target_uses_local_names_and_holds_unselected_joints( + self, robot_config + ): + """Final endpoint for convergence wait is local and preserves uncommanded joints.""" + module = _make_module_with_monitor(robot_config) + module._world_monitor.get_current_joint_state.side_effect = None + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["joint1", "joint2", "joint3"], position=[9.0, 8.0, 7.0] + ) + module._last_trajectory = _successful_generated_trajectory() + + target = module._final_trajectory_target_for_robot("test_arm") + + assert target is not None + assert target.name == ["joint1", "joint2", "joint3"] + assert target.position == pytest.approx([0.3, 0.4, 7.0]) + + def test_wait_for_execution_convergence_polls_until_joints_reach_target(self, robot_config): + """Convergence wait uses live joint state, not only coordinator task completion.""" + module = _make_module_with_monitor(robot_config) + module.config.execution_settle_timeout = 0.1 + module.config.execution_joint_tolerance = 0.03 + module.config.execution_poll_interval = 0.001 + module._last_trajectory = _successful_generated_trajectory() + module._world_monitor.get_current_joint_state.side_effect = None + module._world_monitor.get_current_joint_state.side_effect = [ + JointState(name=["joint1", "joint2", "joint3"], position=[9.0, 8.0, 7.0]), + JointState(name=["joint1", "joint2", "joint3"], position=[0.32, 0.42, 7.01]), + ] + + assert module._wait_for_execution_convergence("test_arm") is True + + def test_wait_for_execution_convergence_reports_timeout_when_joints_do_not_settle( + self, robot_config + ): + """Sequential skills fail fast when the robot never reaches the trajectory endpoint.""" + module = _make_module_with_monitor(robot_config) + module.config.execution_settle_timeout = 0.001 + module.config.execution_joint_tolerance = 0.03 + module.config.execution_poll_interval = 0.001 + module._last_trajectory = _successful_generated_trajectory() + module._world_monitor.get_current_joint_state.side_effect = None + module._world_monitor.get_current_joint_state.return_value = JointState( + name=["joint1", "joint2", "joint3"], position=[9.0, 8.0, 7.0] + ) + + assert module._wait_for_execution_convergence("test_arm") is False + def _make_module_with_monitor(*configs: RobotModelConfig) -> ManipulationModule: """Create a ManipulationModule with a mocked world monitor and robots configured.""" diff --git a/dimos/msgs/grasping_msgs/GraspDebugMarkers.py b/dimos/msgs/grasping_msgs/GraspDebugMarkers.py new file mode 100644 index 0000000000..67869b941e --- /dev/null +++ b/dimos/msgs/grasping_msgs/GraspDebugMarkers.py @@ -0,0 +1,232 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Debug Rerun markers for agentic grasp execution.""" + +from __future__ import annotations + +import json +import time +from types import ModuleType +from typing import BinaryIO, cast + +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.types.timestamped import Timestamped + + +class GraspDebugMarkers(Timestamped): + """Rerun-only grasp debug overlay encoded as a lightweight JSON message.""" + + msg_name = "grasping_msgs.GraspDebugMarkers" + + def __init__( + self, + *, + frame_id: str = "world", + ts: float = 0.0, + bbox_center: Vector3 | None = None, + bbox_size: Vector3 | None = None, + candidate_poses: list[Pose] | None = None, + selected_candidate_index: int | None = None, + pregrasp_pose: Pose | None = None, + final_pose: Pose | None = None, + label: str = "agentic grasp debug", + ) -> None: + self.frame_id = frame_id + self.ts = ts if ts != 0.0 else time.time() + self.bbox_center = bbox_center + self.bbox_size = bbox_size + self.candidate_poses = candidate_poses if candidate_poses is not None else [] + self.selected_candidate_index = selected_candidate_index + self.pregrasp_pose = pregrasp_pose + self.final_pose = final_pose + self.label = label + + def lcm_encode(self) -> bytes: + payload = { + "frame_id": self.frame_id, + "ts": self.ts, + "bbox_center": _vector_to_list(self.bbox_center), + "bbox_size": _vector_to_list(self.bbox_size), + "candidate_poses": [_pose_to_payload(pose) for pose in self.candidate_poses], + "selected_candidate_index": self.selected_candidate_index, + "pregrasp_pose": _pose_to_payload(self.pregrasp_pose), + "final_pose": _pose_to_payload(self.final_pose), + "label": self.label, + } + return json.dumps(payload, separators=(",", ":")).encode("utf-8") + + encode = lcm_encode + + @classmethod + def lcm_decode(cls, data: bytes | BinaryIO) -> GraspDebugMarkers: + raw = data if isinstance(data, bytes) else data.read() + payload = cast("dict[str, object]", json.loads(raw.decode("utf-8"))) + return cls( + frame_id=str(payload.get("frame_id", "world")), + ts=_float_from_payload(payload.get("ts", 0.0)), + bbox_center=_vector_from_payload(payload.get("bbox_center")), + bbox_size=_vector_from_payload(payload.get("bbox_size")), + candidate_poses=[ + _pose_from_payload(item) + for item in cast("list[object]", payload["candidate_poses"]) + ], + selected_candidate_index=_optional_int(payload.get("selected_candidate_index")), + pregrasp_pose=_pose_from_payload(payload.get("pregrasp_pose")), + final_pose=_pose_from_payload(payload.get("final_pose")), + label=str(payload.get("label", "agentic grasp debug")), + ) + + decode = lcm_decode + + def to_rerun(self) -> list[tuple[str, object]]: + """Render bbox, candidates, selected target, and pregrasp/final path.""" + + import rerun as rr + + base = "world/debug/grasp" + entries: list[tuple[str, object]] = [] + if self.bbox_center is not None and self.bbox_size is not None: + entries.append( + ( + f"{base}/target_bbox", + rr.Boxes3D( + centers=[_vector_to_list(self.bbox_center)], + half_sizes=[_vector_to_list(self.bbox_size * 0.5)], + colors=[[0, 180, 255, 80]], + labels=[self.label], + show_labels=True, + fill_mode="majorwireframe", + ), + ) + ) + if self.candidate_poses: + positions = [_vector_to_list(pose.position) for pose in self.candidate_poses] + labels = [f"gpd[{index}]" for index in range(len(self.candidate_poses))] + colors = [ + [0, 255, 80] if index == self.selected_candidate_index else [255, 220, 0] + for index in range(len(self.candidate_poses)) + ] + entries.append( + ( + f"{base}/candidate_points", + rr.Points3D( + positions=positions, + colors=colors, + radii=[0.012] * len(positions), + labels=labels, + show_labels=True, + ), + ) + ) + if self.pregrasp_pose is not None and self.final_pose is not None: + entries.append( + ( + f"{base}/pregrasp_to_final", + rr.LineStrips3D( + strips=[ + [ + _vector_to_list(self.pregrasp_pose.position), + _vector_to_list(self.final_pose.position), + ] + ], + colors=[[0, 255, 255]], + radii=[0.006], + labels=["pregrasp → final"], + show_labels=True, + ), + ) + ) + for name, pose, length in ( + ("pregrasp_pose", self.pregrasp_pose, 0.055), + ("final_grasp_pose", self.final_pose, 0.070), + ): + if pose is not None: + entries.append((f"{base}/{name}", _pose_axes(rr, pose, length))) + return entries + + +def _pose_axes(rr: ModuleType, pose: Pose, length: float) -> object: + x_axis = pose.orientation.rotate_vector(Vector3(length, 0.0, 0.0)) + y_axis = pose.orientation.rotate_vector(Vector3(0.0, length, 0.0)) + z_axis = pose.orientation.rotate_vector(Vector3(0.0, 0.0, length)) + origin = _vector_to_list(pose.position) + return rr.Arrows3D( + origins=[origin, origin, origin], + vectors=[_vector_to_list(x_axis), _vector_to_list(y_axis), _vector_to_list(z_axis)], + colors=[[255, 60, 60], [60, 255, 60], [80, 140, 255]], + labels=["+X", "+Y", "+Z"], + show_labels=True, + ) + + +def _pose_to_payload(pose: Pose | None) -> dict[str, list[float]] | None: + if pose is None: + return None + return { + "position": _vector_to_xyz(pose.position), + "orientation": [ + pose.orientation.x, + pose.orientation.y, + pose.orientation.z, + pose.orientation.w, + ], + } + + +def _pose_from_payload(value: object) -> Pose | None: + if value is None: + return None + payload = cast("dict[str, object]", value) + position = _vector_from_payload(payload["position"]) + orientation = _quat_from_payload(payload["orientation"]) + if position is None or orientation is None: + return None + return Pose((position, orientation)) + + +def _vector_to_list(vector: Vector3 | None) -> list[float] | None: + if vector is None: + return None + return [float(vector.x), float(vector.y), float(vector.z)] + + +def _vector_to_xyz(vector: Vector3) -> list[float]: + return [float(vector.x), float(vector.y), float(vector.z)] + + +def _vector_from_payload(value: object) -> Vector3 | None: + if value is None: + return None + items = cast("list[float]", value) + return Vector3(float(items[0]), float(items[1]), float(items[2])) + + +def _quat_from_payload(value: object) -> Quaternion | None: + if value is None: + return None + items = cast("list[float]", value) + return Quaternion([float(items[0]), float(items[1]), float(items[2]), float(items[3])]) + + +def _optional_int(value: object) -> int | None: + if value is None: + return None + return int(cast("int | float", value)) + + +def _float_from_payload(value: object) -> float: + return float(cast("int | float | str", value)) diff --git a/dimos/perception/object_scene_registration.py b/dimos/perception/object_scene_registration.py index 1988d4d993..d54943c340 100644 --- a/dimos/perception/object_scene_registration.py +++ b/dimos/perception/object_scene_registration.py @@ -22,7 +22,7 @@ from dimos.agents.annotation import skill from dimos.core.core import rpc -from dimos.core.module import Module +from dimos.core.module import Module, ModuleConfig from dimos.core.stream import In, Out from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject @@ -53,9 +53,21 @@ _REGISTERED_BOUNDS_MIN_EXTENT_M = 0.01 +class ObjectSceneRegistrationConfig(ModuleConfig): + target_frame: str = "map" + prompt_mode: YoloePromptMode = YoloePromptMode.LRPC + distance_threshold: float = 0.2 + min_detections_for_permanent: int = 6 + max_distance: float = 0.0 + use_aabb: bool = False + max_obstacle_width: float = 0.0 + + class ObjectSceneRegistrationModule(Module): """Module for detecting objects in camera images using YOLO-E with 2D and 3D detection.""" + config: ObjectSceneRegistrationConfig + color_image: In[Image] depth_image: In[Image] camera_info: In[CameraInfo] diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 5fbc2376e2..8527f2eb24 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -71,6 +71,7 @@ "keyboard-teleop-xarm7": "dimos.robot.manipulators.xarm.blueprints.teleop:keyboard_teleop_xarm7", "learning-collect-quest-piper": "dimos.learning.collection.blueprint:learning_collect_quest_piper", "learning-collect-quest-xarm7": "dimos.learning.collection.blueprint:learning_collect_quest_xarm7", + "manual-agentic-gpd-mujoco-grasp-demo": "dimos.robot.manipulators.xarm.blueprints.simulation:manual_agentic_gpd_mujoco_grasp_demo", "mid360": "dimos.hardware.sensors.lidar.livox.livox_blueprints:mid360", "mid360-fastlio": "dimos.hardware.sensors.lidar.fastlio2.fastlio_blueprints:mid360_fastlio", "mid360-fastlio-ray-trace": "dimos.hardware.sensors.lidar.fastlio2.fastlio_blueprints:mid360_fastlio_ray_trace", @@ -147,6 +148,7 @@ all_modules = { + "agentic-grasp-manipulation-module": "dimos.manipulation.agentic_manipulation_module.AgenticGraspManipulationModule", "agentic-manipulation-module": "dimos.manipulation.agentic_manipulation_module.AgenticManipulationModule", "alfred-high-level": "dimos.robot.diy.alfred.effector_high_level.AlfredHighLevel", "arm-teleop-module": "dimos.teleop.quest.quest_extensions.ArmTeleopModule", diff --git a/dimos/robot/manipulators/xarm/blueprints/simulation.py b/dimos/robot/manipulators/xarm/blueprints/simulation.py index 2c4ac3dec1..ee4a681d93 100644 --- a/dimos/robot/manipulators/xarm/blueprints/simulation.py +++ b/dimos/robot/manipulators/xarm/blueprints/simulation.py @@ -17,11 +17,13 @@ from __future__ import annotations import math +from types import ModuleType from dimos_gpd_grasp_demo.blueprint import gpd_grasp_gen_blueprint from dimos_gpd_grasp_demo.gpd_grasp_gen_module import GPDGraspGenModule from dimos.core.coordination.blueprints import autoconnect +from dimos.manipulation.agentic_manipulation_module import AgenticGraspManipulationModule from dimos.manipulation.grasping.grasping import GraspingModule from dimos.manipulation.grasping.pointcloud_grasp_demo_controller import ( PointcloudGraspDemoController, @@ -29,6 +31,9 @@ from dimos.manipulation.grasping.target_grasp_demo_controller import TargetGraspDemoController from dimos.manipulation.grasping.vgn_grasp_gen_module import VGNGraspGenModule from dimos.manipulation.pick_and_place_module import PickAndPlaceModule +from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake +from dimos.perception.detection.detectors.yoloe import YoloePromptMode from dimos.perception.object_scene_registration import ObjectSceneRegistrationModule from dimos.perception.reconstruction import SceneReconstructionModule from dimos.robot.manipulators.common.blueprints import coordinator, trajectory_task @@ -39,9 +44,61 @@ ) from dimos.simulation.engines.mujoco_sim_module import MujocoSimModule from dimos.visualization.rerun.bridge import RerunBridgeModule +from dimos.visualization.rerun.urdf import unique_link_names, urdf_visuals_to_rerun XARM7_SIM_HOME = [0.0, 0.0, 0.0, 0.0, 0.0, -0.7, 0.0] XARM7_VGN_OBSERVATION_HOME = [0.0, -0.247, 0.0, 0.909, 0.0, 1.15644, 0.0] +XARM7_RERUN_LINK_FRAMES = unique_link_names( + [ + "link_base", + "link1", + "link2", + "link3", + "link4", + "link5", + "link6", + "link7", + "link_eef", + "xarm_gripper_base_link", + "left_outer_knuckle", + "left_finger", + "left_inner_knuckle", + "right_outer_knuckle", + "right_finger", + "right_inner_knuckle", + "link_tcp", + ] +) + +XARM7_RERUN_HIGHLIGHT_LINKS = ["link7", "xarm_gripper_base_link", "link_tcp"] + + +def _manual_agentic_xarm7_model_config() -> RobotModelConfig: + return make_xarm7_model_config( + name="arm", + add_gripper=True, + tf_extra_links=XARM7_RERUN_LINK_FRAMES, + home_joints=XARM7_VGN_OBSERVATION_HOME, + pre_grasp_offset=0.05, + ) + + +def _manual_agentic_xarm7_rerun_static(rr: ModuleType) -> list[tuple[str, object]]: + robot_config = _manual_agentic_xarm7_model_config() + urdf_path = prepare_urdf_for_drake( + robot_config.model_path, + robot_config.package_paths, + robot_config.xacro_args, + robot_config.auto_convert_meshes, + robot_config.base_link if robot_config.strip_model_world_joint else None, + ) + return urdf_visuals_to_rerun( + rr, + urdf_path, + entity_prefix="world/robot", + highlight_links=XARM7_RERUN_HIGHLIGHT_LINKS, + ) + _xarm7_sim_hw = make_xarm_hardware( "arm", @@ -154,3 +211,44 @@ (GPDGraspGenModule, "grasp_candidates", "gpd_grasp_candidates"), ] ) + + +manual_agentic_gpd_mujoco_grasp_demo = autoconnect( + PickAndPlaceModule.blueprint( + robots=[_manual_agentic_xarm7_model_config()], + planning_timeout=10.0, + visualization={"backend": "meshcat"}, + ), + MujocoSimModule.blueprint( + address=str(XARM7_SIM_PATH), + headless=False, + dof=7, + camera_name="wrist_camera", + base_frame_id="link7", + enable_depth=True, + enable_color=True, + enable_pointcloud=True, + camera_info_fps=5.0, + initial_joint_positions=XARM7_VGN_OBSERVATION_HOME, + ), + ObjectSceneRegistrationModule.blueprint( + target_frame="world", + prompt_mode=YoloePromptMode.PROMPT, + min_detections_for_permanent=1, + use_aabb=True, + ), + gpd_grasp_gen_blueprint(), + AgenticGraspManipulationModule.blueprint(), + coordinator( + hardware=[_xarm7_sim_hw], + tasks=[trajectory_task(_xarm7_sim_hw)], + ), + RerunBridgeModule.blueprint( + static={"manual_agentic_xarm7_urdf": _manual_agentic_xarm7_rerun_static} + ), +).remappings( + [ + (GPDGraspGenModule, "grasp_candidates", "gpd_grasp_candidates"), + (AgenticGraspManipulationModule, "_grasp_gen", GPDGraspGenModule), + ] +) diff --git a/dimos/robot/manipulators/xarm/blueprints/test_gpd_mujoco_grasp_demo.py b/dimos/robot/manipulators/xarm/blueprints/test_gpd_mujoco_grasp_demo.py index d5432cd7b7..6db5503dfd 100644 --- a/dimos/robot/manipulators/xarm/blueprints/test_gpd_mujoco_grasp_demo.py +++ b/dimos/robot/manipulators/xarm/blueprints/test_gpd_mujoco_grasp_demo.py @@ -22,24 +22,32 @@ import pytest from pytest_mock import MockerFixture +from dimos.agents.skill_result import SkillResult from dimos.core.coordination.module_coordinator import ModuleCoordinator from dimos.core.runtime_environment import PythonProjectRuntimeEnvironment +from dimos.manipulation.agentic_manipulation_module import AgenticGraspManipulationModule from dimos.manipulation.grasping.grasping import GraspingModule +from dimos.manipulation.grasping.manual_agentic_gpd_grasp_demo import ( + run_manual_agentic_gpd_grasp_sequence, +) from dimos.manipulation.grasping.pointcloud_grasp_demo_controller import ( PointcloudGraspDemoController, ) from dimos.manipulation.grasping.target_grasp_demo_controller import TargetGraspDemoController from dimos.manipulation.grasping.vgn_grasp_gen_module import VGNGraspGenModule from dimos.manipulation.pick_and_place_module import PickAndPlaceModule +from dimos.manipulation.skill_errors import ManipulationSkillError from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.grasping_msgs.GraspCandidateArray import GraspCandidateArray from dimos.msgs.perception_msgs.RegisteredObject import RegisteredObject from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.perception.detection.detectors.yoloe import YoloePromptMode from dimos.perception.object_scene_registration import ObjectSceneRegistrationModule from dimos.perception.reconstruction import SceneReconstructionModule from dimos.robot.all_blueprints import all_blueprints from dimos.robot.manipulators.xarm.blueprints.simulation import ( gpd_mujoco_grasp_demo, + manual_agentic_gpd_mujoco_grasp_demo, vgn_mujoco_grasp_demo, xarm_perception_sim, ) @@ -115,6 +123,45 @@ def test_gpd_mujoco_grasp_demo_registry_name_is_stable() -> None: ) +def test_manual_agentic_gpd_mujoco_grasp_demo_wires_execution_facade() -> None: + manual_modules = {atom.module for atom in manual_agentic_gpd_mujoco_grasp_demo.blueprints} + generation_only_modules = {atom.module for atom in gpd_mujoco_grasp_demo.blueprints} + + assert AgenticGraspManipulationModule in manual_modules + assert PickAndPlaceModule in manual_modules + assert MujocoSimModule in manual_modules + assert ObjectSceneRegistrationModule in manual_modules + assert GPDGraspGenModule in manual_modules + assert RerunBridgeModule in manual_modules + assert PointcloudGraspDemoController not in manual_modules + assert GraspingModule not in manual_modules + assert AgenticGraspManipulationModule not in generation_only_modules + assert PickAndPlaceModule not in generation_only_modules + + +def test_manual_agentic_gpd_mujoco_grasp_demo_uses_gpd_runtime_and_topics() -> None: + placements = dict(manual_agentic_gpd_mujoco_grasp_demo.runtime_placement_map) + scene_registration_atom = next( + atom + for atom in manual_agentic_gpd_mujoco_grasp_demo.blueprints + if atom.module is ObjectSceneRegistrationModule + ) + + assert placements == {GPDGraspGenModule: GPD_GRASP_DEMO_ENV_NAME} + assert scene_registration_atom.kwargs["prompt_mode"] is YoloePromptMode.PROMPT + assert ( + manual_agentic_gpd_mujoco_grasp_demo.remapping_map[(GPDGraspGenModule, "grasp_candidates")] + == "gpd_grasp_candidates" + ) + + +def test_manual_agentic_gpd_mujoco_grasp_demo_registry_name_is_stable() -> None: + assert ( + all_blueprints["manual-agentic-gpd-mujoco-grasp-demo"] + == "dimos.robot.manipulators.xarm.blueprints.simulation:manual_agentic_gpd_mujoco_grasp_demo" + ) + + class _SceneRegistrationFake: def __init__(self, target: RegisteredObject) -> None: self.target = target @@ -162,6 +209,43 @@ def generate_grasps( return "generated" +class _ManualAgenticGraspClientFake: + def __init__(self, scan_failures_before_success: int = 0) -> None: + self.scan_failures_before_success = scan_failures_before_success + self.calls: list[tuple[str, tuple[str | int | None, ...]]] = [] + self.stopped = False + + def scan_objects(self, target_name: str = "object") -> SkillResult[ManipulationSkillError]: + self.calls.append(("scan_objects", (target_name,))) + if self.scan_failures_before_success > 0: + self.scan_failures_before_success -= 1 + return SkillResult[ManipulationSkillError].fail( + "OBJECT_NOT_DETECTED", "target not ready" + ) + return SkillResult[ManipulationSkillError].ok("registered", target_count=1) + + def generate_grasps( + self, + target_name: str = "object", + object_id: str | None = None, + filter_collisions: bool = False, + ) -> SkillResult[ManipulationSkillError]: + del object_id, filter_collisions + self.calls.append(("generate_grasps", (target_name,))) + return SkillResult[ManipulationSkillError].ok("generated", candidate_count=2) + + def execute_grasp( + self, + candidate_index: int = 0, + robot_name: str | None = None, + ) -> SkillResult[ManipulationSkillError]: + self.calls.append(("execute_grasp", (candidate_index, robot_name))) + return SkillResult[ManipulationSkillError].ok("executed") + + def stop_rpc_client(self) -> None: + self.stopped = True + + def test_pointcloud_demo_controller_calls_pointcloud_generation_only(mocker: MockerFixture) -> None: target = RegisteredObject( object_id="obj-1", @@ -194,6 +278,28 @@ def test_pointcloud_demo_controller_calls_pointcloud_generation_only(mocker: Moc controller.stop() +def test_manual_agentic_gpd_helper_runs_deterministic_tool_sequence() -> None: + client = _ManualAgenticGraspClientFake(scan_failures_before_success=1) + + results = run_manual_agentic_gpd_grasp_sequence( + target_name="sphere", + candidate_index=0, + robot_name="arm", + scan_attempts=2, + retry_interval_s=0.0, + client=client, + ) + + assert [result.message for result in results] == ["registered", "generated", "executed"] + assert client.calls == [ + ("scan_objects", ("sphere",)), + ("scan_objects", ("sphere",)), + ("generate_grasps", ("sphere",)), + ("execute_grasp", (0, "arm")), + ] + assert client.stopped is False + + @pytest.mark.skipif( not Path("packages/dimos-gpd-grasp-demo/.venv/bin/python").exists(), reason=( @@ -211,3 +317,38 @@ def test_gpd_mujoco_grasp_demo_prepared_runtime_smoke_gate() -> None: {"g": {"viewer": "none", "n_workers": 1}}, ) coordinator.stop() + + +@pytest.mark.skipif( + not Path("packages/dimos-gpd-grasp-demo/.venv/bin/python").exists(), + reason=( + "GPD prepared runtime is not available; run `uv run dimos runtime prepare " + "manual-agentic-gpd-mujoco-grasp-demo --runtime dimos-gpd-grasp-demo` first" + ), +) +@pytest.mark.skipif( + os.environ.get("DIMOS_RUN_MANUAL_AGENTIC_GPD_MUJOCO_SMOKE") != "1", + reason=( + "Set DIMOS_RUN_MANUAL_AGENTIC_GPD_MUJOCO_SMOKE=1 to run the manual " + "agentic GPD MuJoCo grasp execution smoke" + ), +) +def test_manual_agentic_gpd_mujoco_grasp_demo_prepared_runtime_smoke_gate() -> None: + coordinator = ModuleCoordinator.build( + manual_agentic_gpd_mujoco_grasp_demo, + {"g": {"viewer": "none", "n_workers": 1}}, + ) + try: + scan, generate, execute = run_manual_agentic_gpd_grasp_sequence( + target_name="sphere", + candidate_index=0, + scan_attempts=90, + retry_interval_s=0.5, + ) + + assert scan.success is True + assert generate.success is True + assert int(generate.metadata["candidate_count"]) > 0 + assert execute.success is True + finally: + coordinator.stop() diff --git a/dimos/simulation/engines/mujoco_engine.py b/dimos/simulation/engines/mujoco_engine.py index b6b47edffa..ae06e98c76 100644 --- a/dimos/simulation/engines/mujoco_engine.py +++ b/dimos/simulation/engines/mujoco_engine.py @@ -552,6 +552,15 @@ def read_camera(self, camera_name: str) -> CameraFrame | None: with self._camera_lock: return self._camera_frames.get(camera_name) + def get_body_pose( + self, body_name: str + ) -> tuple[NDArray[np.float64], NDArray[np.float64]] | None: + """Return a MuJoCo body pose as world position and rotation matrix.""" + body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, body_name) + if body_id < 0: + return None + return self._data.xpos[body_id].copy(), self._data.xmat[body_id].copy().reshape(3, 3) + def get_camera_fovy(self, camera_name: str) -> float | None: """Get vertical field of view for a named camera, in degrees.""" cam_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_CAMERA, camera_name) diff --git a/dimos/simulation/engines/mujoco_sim_module.py b/dimos/simulation/engines/mujoco_sim_module.py index 4029a29054..9e1d3f9f73 100644 --- a/dimos/simulation/engines/mujoco_sim_module.py +++ b/dimos/simulation/engines/mujoco_sim_module.py @@ -82,6 +82,32 @@ def _find_sensor_slice(model: mujoco.MjModel, *names: str, dim: int = 3) -> slic _RX180 = R.from_euler("x", 180, degrees=True) +def _pose_matrix( + position: NDArray[np.float64], rotation: NDArray[np.float64] +) -> NDArray[np.float64]: + matrix = np.eye(4, dtype=np.float64) + matrix[:3, :3] = rotation + matrix[:3, 3] = position + return matrix + + +def _transform_from_matrix( + matrix: NDArray[np.float64], *, frame_id: str, child_frame_id: str, ts: float +) -> Transform: + quat = Quaternion.from_rotation_matrix(matrix[:3, :3]) + return Transform( + translation=Vector3( + float(matrix[0, 3]), + float(matrix[1, 3]), + float(matrix[2, 3]), + ), + rotation=quat, + frame_id=frame_id, + child_frame_id=child_frame_id, + ts=ts, + ) + + def _default_identity_transform() -> Transform: return Transform( translation=Vector3(0.0, 0.0, 0.0), @@ -707,32 +733,37 @@ def _publish_tf(self, ts: float, frame: CameraFrame | None) -> None: return mj_rot = R.from_matrix(frame.cam_mat.reshape(3, 3)) optical_rot = mj_rot * _RX180 - q = optical_rot.as_quat() # xyzw - pos = Vector3( - float(frame.cam_pos[0]), - float(frame.cam_pos[1]), - float(frame.cam_pos[2]), - ) - rot = Quaternion(float(q[0]), float(q[1]), float(q[2]), float(q[3])) + camera_position = frame.cam_pos + camera_rotation = mj_rot.as_matrix() + optical_rotation = optical_rot.as_matrix() + + parent_frame = "world" + camera_transform = _pose_matrix(camera_position, camera_rotation) + optical_transform = _pose_matrix(camera_position, optical_rotation) + base_pose = self._engine.get_body_pose(self.config.base_frame_id) if self._engine else None + if base_pose is not None: + parent_frame = self.config.base_frame_id + base_transform = _pose_matrix(base_pose[0], base_pose[1]) + inv_base_transform = np.linalg.inv(base_transform) + camera_transform = inv_base_transform @ camera_transform + optical_transform = inv_base_transform @ optical_transform + self.tf.publish( - Transform( - translation=pos, - rotation=rot, - frame_id="world", + _transform_from_matrix( + optical_transform, + frame_id=parent_frame, child_frame_id=self._color_optical_frame, ts=ts, ), - Transform( - translation=pos, - rotation=rot, - frame_id="world", + _transform_from_matrix( + optical_transform, + frame_id=parent_frame, child_frame_id=self._depth_optical_frame, ts=ts, ), - Transform( - translation=pos, - rotation=rot, - frame_id="world", + _transform_from_matrix( + camera_transform, + frame_id=parent_frame, child_frame_id=self._camera_link, ts=ts, ), diff --git a/dimos/simulation/engines/test_mujoco_sim_module.py b/dimos/simulation/engines/test_mujoco_sim_module.py index 1a37941ef0..244c334414 100644 --- a/dimos/simulation/engines/test_mujoco_sim_module.py +++ b/dimos/simulation/engines/test_mujoco_sim_module.py @@ -19,7 +19,8 @@ import numpy as np -from dimos.simulation.engines.mujoco_sim_module import MujocoSimModule +from dimos.simulation.engines.mujoco_engine import CameraFrame +from dimos.simulation.engines.mujoco_sim_module import MujocoSimModule, MujocoSimModuleConfig class _FakeData: @@ -63,3 +64,43 @@ def signal_ready(self, *, num_joints: int) -> None: module._publish_shm_and_lcm(_FakeEngine) assert events == ["joint_state", "imu", "ready"] + + +def test_camera_tf_is_published_relative_to_configured_base_frame() -> None: + module = object.__new__(MujocoSimModule) + module.config = MujocoSimModuleConfig(base_frame_id="link7") + + class _FakeEngine: + def get_body_pose(self, body_name: str): # type: ignore[no-untyped-def] + assert body_name == "link7" + return np.array([1.0, 2.0, 2.0]), np.eye(3) + + class _FakeTf: + transforms = () + + def publish(self, *transforms: Any) -> None: + self.transforms = transforms + + fake_tf = _FakeTf() + module._engine = _FakeEngine() + module._tf = fake_tf + frame = CameraFrame( + rgb=np.zeros((1, 1, 3), dtype=np.uint8), + depth=np.ones((1, 1), dtype=np.float32), + cam_pos=np.array([1.0, 2.0, 3.0]), + cam_mat=np.eye(3), + fovy=60.0, + timestamp=1.0, + ) + + module._publish_tf(10.0, frame) + + color_tf, depth_tf, camera_link_tf = fake_tf.transforms + assert color_tf.frame_id == "link7" + assert color_tf.child_frame_id == "wrist_camera_color_optical_frame" + assert np.allclose(color_tf.translation.to_numpy(), [0.0, 0.0, 1.0]) + assert depth_tf.frame_id == "link7" + assert depth_tf.child_frame_id == "wrist_camera_depth_optical_frame" + assert camera_link_tf.frame_id == "link7" + assert camera_link_tf.child_frame_id == "wrist_camera_link" + assert np.allclose(camera_link_tf.translation.to_numpy(), [0.0, 0.0, 1.0]) diff --git a/dimos/visualization/rerun/bridge.py b/dimos/visualization/rerun/bridge.py index 2f5fb1efa9..3a3dd033f7 100644 --- a/dimos/visualization/rerun/bridge.py +++ b/dimos/visualization/rerun/bridge.py @@ -169,7 +169,7 @@ class Config(ModuleConfig): visual_override: dict[Glob | str, Callable[[Any], Archetype] | None] = field( default_factory=dict ) - static: dict[str, Callable[[Any], Archetype]] = field(default_factory=dict) + static: dict[str, Callable[[Any], Any]] = field(default_factory=dict) max_hz: dict[str, float] = field(default_factory=dict) entity_prefix: str = "world" @@ -433,7 +433,10 @@ def _log_connect_hints(self, grpc_port: int) -> None: def _log_static(self) -> None: for entity_path, factory in self.config.static.items(): data = factory(rr) - if isinstance(data, list): + if is_rerun_multi(data): + for path, archetype in data: + rr.log(path, archetype, static=True) + elif isinstance(data, list): for archetype in data: rr.log(entity_path, archetype, static=True) else: diff --git a/dimos/visualization/rerun/urdf.py b/dimos/visualization/rerun/urdf.py new file mode 100644 index 0000000000..06cad8198a --- /dev/null +++ b/dimos/visualization/rerun/urdf.py @@ -0,0 +1,167 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""URDF visual logging helpers for Rerun.""" + +from __future__ import annotations + +from collections.abc import Iterable +from math import cos, sin +from pathlib import Path +from types import ModuleType +import xml.etree.ElementTree as ET + + +def urdf_visuals_to_rerun( + rr: ModuleType, + urdf_path: Path | str, + *, + entity_prefix: str = "world/robot", + axes_scale: float = 0.04, + highlight_links: Iterable[str] = (), +) -> list[tuple[str, object]]: + """Convert URDF link visuals into static Rerun asset logs. + + Dynamic link poses are expected to be logged separately as Rerun TF/entity + transforms. This helper attaches each ``{entity_prefix}/{link}`` entity to + the dynamic ``tf#/{link}`` frame, then logs visual meshes and axes below it + so they follow the corresponding link transform in Rerun. + """ + + root = ET.parse(urdf_path).getroot() + entries: list[tuple[str, object]] = [] + highlighted = set(highlight_links) + for link in root.findall("link"): + link_name = link.attrib.get("name") + if not link_name: + continue + + link_path = f"{entity_prefix}/{link_name}" + entries.append((link_path, rr.Transform3D(parent_frame=f"tf#/{link_name}"))) + scale = axes_scale * 2.5 if link_name in highlighted else axes_scale + entries.append((f"{link_path}/axes", _link_axes(rr, scale, link_name=link_name))) + if link_name in highlighted: + entries.append((f"{link_path}/origin", _link_origin(rr, link_name))) + for index, visual in enumerate(link.findall("visual")): + mesh = visual.find("geometry/mesh") + if mesh is None: + continue + filename = mesh.attrib.get("filename") + if not filename: + continue + mesh_path = Path(filename) + if not mesh_path.exists(): + continue + + origin = visual.find("origin") + translation = _parse_triplet(origin.attrib.get("xyz") if origin is not None else None) + rpy = _parse_triplet(origin.attrib.get("rpy") if origin is not None else None) + scale = _parse_triplet(mesh.attrib.get("scale"), default=(1.0, 1.0, 1.0)) + visual_path = f"{link_path}/visual_{index}" + entries.append( + ( + visual_path, + rr.Transform3D( + translation=translation, + rotation=rr.Quaternion(xyzw=_quat_xyzw_from_rpy(*rpy)), + scale=scale, + ), + ) + ) + entries.append((visual_path, rr.Asset3D(path=mesh_path))) + + entries.extend(_scene_axes(rr)) + return entries + + +def _link_axes(rr: ModuleType, scale: float, *, link_name: str) -> object: + return rr.Arrows3D( + origins=[[0.0, 0.0, 0.0]] * 3, + vectors=[[scale, 0.0, 0.0], [0.0, scale, 0.0], [0.0, 0.0, scale]], + colors=[[255, 60, 60], [60, 255, 60], [80, 140, 255]], + labels=[f"{link_name} +X", f"{link_name} +Y", f"{link_name} +Z"], + show_labels=True, + ) + + +def _link_origin(rr: ModuleType, link_name: str) -> object: + return rr.Points3D( + positions=[[0.0, 0.0, 0.0]], + colors=[[255, 0, 255]], + radii=[0.018], + labels=[link_name], + show_labels=True, + ) + + +def _scene_axes(rr: ModuleType) -> list[tuple[str, object]]: + return [ + ( + "world", + # MuJoCo/xArm scenes use a robotics world convention: +X points out + # from the robot toward the table, +Y is lateral, and +Z is up. + # Without this, Rerun's default 3D basis can make the whole scene + # look rolled even when all relative transforms are correct. + rr.ViewCoordinates.FLU, + ), + ( + "world/debug/scene_axes", + rr.Arrows3D( + origins=[[0.0, 0.0, 0.0]] * 3, + vectors=[[0.20, 0.0, 0.0], [0.0, 0.20, 0.0], [0.0, 0.0, 0.20]], + colors=[[255, 60, 60], [60, 255, 60], [80, 140, 255]], + labels=["world +X table", "world +Y lateral", "world +Z up"], + show_labels=True, + ), + ), + ] + + +def _parse_triplet( + value: str | None, default: tuple[float, float, float] = (0.0, 0.0, 0.0) +) -> list[float]: + if value is None: + return [*default] + parts = [float(part) for part in value.split()] + if len(parts) != 3: + return [*default] + return parts + + +def _quat_xyzw_from_rpy(roll: float, pitch: float, yaw: float) -> list[float]: + cy = cos(yaw * 0.5) + sy = sin(yaw * 0.5) + cp = cos(pitch * 0.5) + sp = sin(pitch * 0.5) + cr = cos(roll * 0.5) + sr = sin(roll * 0.5) + return [ + sr * cp * cy - cr * sp * sy, + cr * sp * cy + sr * cp * sy, + cr * cp * sy - sr * sp * cy, + cr * cp * cy + sr * sp * sy, + ] + + +def unique_link_names(names: Iterable[str]) -> list[str]: + """Return link names in first-seen order without duplicates.""" + + seen: set[str] = set() + result: list[str] = [] + for name in names: + if name in seen: + continue + seen.add(name) + result.append(name) + return result diff --git a/docs/usage/README.md b/docs/usage/README.md index 99d0918d04..dbdde4a837 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -9,6 +9,7 @@ This page explains general concepts. - [Blueprints](/docs/usage/blueprints.md): a way to group modules together and define their connections to each other. - [Runtime environments](/docs/usage/runtime_environments.md): run selected Python modules in named venv or project workers, or resolve native executable settings from named environments. - [VGN MuJoCo Grasp Demo](/docs/usage/vgn_mujoco_grasp_demo.md): opt-in xArm7 TSDF reconstruction and grasp candidate visualization demo. +- [GPD MuJoCo Grasp Demo](/docs/usage/gpd_mujoco_grasp_demo.md): opt-in xArm7 pointcloud GPD candidate generation plus a separate manual agent-facing execution demo. - [RPC](/docs/usage/blueprints.md#calling-the-methods-of-other-modules): how one module can call a method on another module (arguments get serialized to JSON-like binary data). - [Skills](/docs/usage/blueprints.md#defining-skills): An RPC function, except it can be called by an AI agent (a tool for an AI). - Agents: AI that has an objective, access to stream data, and is capable of calling skills as tools. diff --git a/docs/usage/gpd_mujoco_grasp_demo.md b/docs/usage/gpd_mujoco_grasp_demo.md index 2d11d1a974..a012856116 100644 --- a/docs/usage/gpd_mujoco_grasp_demo.md +++ b/docs/usage/gpd_mujoco_grasp_demo.md @@ -24,3 +24,49 @@ Expected Rerun-visible outputs after the registered target is detected and the c `GraspingModule` also publishes compatible `PoseArray` results on `grasps` for downstream code, but candidate visualization in Rerun comes from `gpd_grasp_candidates`. If no target object is registered before the configured timeout, the controller logs an empty-result message and publishes empty target bounds. If GPD returns no candidates after a target is registered, the controller/module logs an explicit empty-result message and still avoids robot execution. + +## Manual agent-facing execution demo + +`manual-agentic-gpd-mujoco-grasp-demo` is a separate opt-in demo that adds motion execution and the `AgenticGraspManipulationModule` tool surface. It keeps `gpd-mujoco-grasp-demo` generation-only, and it still does not use `McpClient`, an autonomous LLM loop, or a model API key. + +Prepare the same optional GPD runtime first: + +```bash +uv run dimos runtime prepare manual-agentic-gpd-mujoco-grasp-demo --runtime dimos-gpd-grasp-demo +``` + +Start the manual execution demo: + +```bash +uv run dimos run manual-agentic-gpd-mujoco-grasp-demo +``` + +Then run the deterministic RPC helper from another terminal. It calls the same agent-facing tools a manual caller would use: + +```bash +uv run python -m dimos.manipulation.grasping.manual_agentic_gpd_grasp_demo \ + --target-name sphere \ + --candidate-index 0 +``` + +Equivalent tool sequence: + +1. `scan_objects("sphere")` +2. `generate_grasps("sphere")` +3. `execute_grasp(0)` + +`execute_grasp` only executes candidates cached by the prior `generate_grasps` call. It does not implicitly rescan the scene or regenerate candidates. + +### Rerun checklist + +During a successful manual run, verify these outputs and motions in Rerun/MuJoCo: + +- the sphere is registered as the Grasp target; +- GPD Grasp candidates appear on `world/gpd_grasp_candidates`; +- the robot approaches the selected candidate; +- the gripper closes at the grasp pose; +- the arm lifts/retracts in world frame after closing. + +### Out of scope for this round + +This demo does not claim autonomous LLM execution, physical robot grasp CI, object-lift pass/fail checks, collision-aware filtering, retries, `grab_object`, place/drop/pick-and-place, or raw plan execution APIs. diff --git a/openspec/changes/manual-agentic-grasp-demo/tasks.md b/openspec/changes/manual-agentic-grasp-demo/tasks.md index 984a1c9ff2..f55a995ac7 100644 --- a/openspec/changes/manual-agentic-grasp-demo/tasks.md +++ b/openspec/changes/manual-agentic-grasp-demo/tasks.md @@ -1,34 +1,34 @@ ## 1. Agent-facing facade contracts -- [ ] 1.1 Extend the agentic manipulation spec/contracts needed for pose motion, world-frame relative motion, home motion, set-gripper, scene scanning, grasp generation, and cached candidate execution. -- [ ] 1.2 Add `AgenticGraspManipulationModule` in `dimos/manipulation/agentic_manipulation_module.py` while preserving the existing dependency-light `AgenticManipulationModule` behavior. -- [ ] 1.3 Implement Round 1 skill metadata/docstrings for `scan_objects`, `generate_grasps`, `execute_grasp`, `move_to_pose`, `move_relative`, `move_along_axis`, `go_home`, `open_gripper`, `close_gripper`, and `set_gripper`. +- [x] 1.1 Extend the agentic manipulation spec/contracts needed for pose motion, world-frame relative motion, home motion, set-gripper, scene scanning, grasp generation, and cached candidate execution. +- [x] 1.2 Add `AgenticGraspManipulationModule` in `dimos/manipulation/agentic_manipulation_module.py` while preserving the existing dependency-light `AgenticManipulationModule` behavior. +- [x] 1.3 Implement Round 1 skill metadata/docstrings for `scan_objects`, `generate_grasps`, `execute_grasp`, `move_to_pose`, `move_relative`, `move_along_axis`, `go_home`, `open_gripper`, `close_gripper`, and `set_gripper`. ## 2. Grasp candidate orchestration -- [ ] 2.1 Implement `scan_objects(...)` delegation to object-scene/perception registration and return clear registered Grasp target summaries. -- [ ] 2.2 Implement `generate_grasps(...)` delegation to the configured grasp generation path and cache generated Grasp candidates for later execution. -- [ ] 2.3 Implement `execute_grasp(candidate_index=0)` as execution-only over cached candidates, including no-cache and invalid-index failures before motion commands. -- [ ] 2.4 Implement the conservative execution sequence for a selected candidate: open gripper, move to pregrasp, move to grasp pose, close gripper, then lift or retract in world frame. -- [ ] 2.5 Implement `move_relative(...)` and `move_along_axis(...)` with `frame="world"` defaults and clear unsupported-frame failures. +- [x] 2.1 Implement `scan_objects(...)` delegation to object-scene/perception registration and return clear registered Grasp target summaries. +- [x] 2.2 Implement `generate_grasps(...)` delegation to the configured grasp generation path and cache generated Grasp candidates for later execution. +- [x] 2.3 Implement `execute_grasp(candidate_index=0)` as execution-only over cached candidates, including no-cache and invalid-index failures before motion commands. +- [x] 2.4 Implement the conservative execution sequence for a selected candidate: open gripper, move to pregrasp, move to grasp pose, close gripper, then lift or retract in world frame. +- [x] 2.5 Implement `move_relative(...)` and `move_along_axis(...)` with `frame="world"` defaults and clear unsupported-frame failures. ## 3. Demo wiring -- [ ] 3.1 Add or update MuJoCo/GPD blueprint wiring for a manual execution demo that includes perception, GPD grasp generation, manipulation execution, and the grasp-capable agentic facade without requiring `McpClient`. -- [ ] 3.2 Preserve the existing candidate-generation-only `gpd-mujoco-grasp-demo` behavior so it remains non-executing. -- [ ] 3.3 Provide a manual driver path using MCP/tool calls or an equivalent deterministic helper sequence for `scan_objects("sphere")`, `generate_grasps("sphere")`, and `execute_grasp(0)`. +- [x] 3.1 Add or update MuJoCo/GPD blueprint wiring for a manual execution demo that includes perception, GPD grasp generation, manipulation execution, and the grasp-capable agentic facade without requiring `McpClient`. +- [x] 3.2 Preserve the existing candidate-generation-only `gpd-mujoco-grasp-demo` behavior so it remains non-executing. +- [x] 3.3 Provide a manual driver path using MCP/tool calls or an equivalent deterministic helper sequence for `scan_objects("sphere")`, `generate_grasps("sphere")`, and `execute_grasp(0)`. ## 4. Tests and validation -- [ ] 4.1 Add simulator-free unit tests for universal facade preservation and grasp-capable facade skill schema/delegation behavior. -- [ ] 4.2 Add unit tests for cached Grasp candidate execution semantics: success path, missing cache, invalid index, and no implicit regenerate behavior. -- [ ] 4.3 Add unit tests for world-frame relative motion defaults and unsupported-frame error handling. -- [ ] 4.4 Add a gated MuJoCo/self-hosted smoke test that validates registered Grasp target discovery, non-empty cached candidates, and `execute_grasp(0)` pipeline completion without requiring object-lift assertion. -- [ ] 4.5 Run focused default tests for agentic manipulation and grasp facade behavior. +- [x] 4.1 Add simulator-free unit tests for universal facade preservation and grasp-capable facade skill schema/delegation behavior. +- [x] 4.2 Add unit tests for cached Grasp candidate execution semantics: success path, missing cache, invalid index, and no implicit regenerate behavior. +- [x] 4.3 Add unit tests for world-frame relative motion defaults and unsupported-frame error handling. +- [x] 4.4 Add a gated MuJoCo/self-hosted smoke test that validates registered Grasp target discovery, non-empty cached candidates, and `execute_grasp(0)` pipeline completion without requiring object-lift assertion. +- [x] 4.5 Run focused default tests for agentic manipulation and grasp facade behavior. ## 5. Documentation -- [ ] 5.1 Document runtime preparation and startup commands for the manual agent-facing GPD MuJoCo grasp demo. -- [ ] 5.2 Document the manual command sequence for scanning the sphere, generating grasps, and executing candidate 0. -- [ ] 5.3 Add the Rerun visual checklist: registered Grasp target, GPD Grasp candidates, robot approach, gripper close, and lift/retract motion. -- [ ] 5.4 Clearly label autonomous LLM execution, physical robot grasp CI, object-lift pass/fail checks, collision-aware filtering, retries, `grab_object`, place/drop/pick-and-place, and raw plan execution as out of scope for this round. +- [x] 5.1 Document runtime preparation and startup commands for the manual agent-facing GPD MuJoCo grasp demo. +- [x] 5.2 Document the manual command sequence for scanning the sphere, generating grasps, and executing candidate 0. +- [x] 5.3 Add the Rerun visual checklist: registered Grasp target, GPD Grasp candidates, robot approach, gripper close, and lift/retract motion. +- [x] 5.4 Clearly label autonomous LLM execution, physical robot grasp CI, object-lift pass/fail checks, collision-aware filtering, retries, `grab_object`, place/drop/pick-and-place, and raw plan execution as out of scope for this round. diff --git a/packages/dimos-gpd-grasp-demo/uv.lock b/packages/dimos-gpd-grasp-demo/uv.lock index ed056223c7..03a8869beb 100644 --- a/packages/dimos-gpd-grasp-demo/uv.lock +++ b/packages/dimos-gpd-grasp-demo/uv.lock @@ -193,15 +193,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "annotation-protocol" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/fd/612c96531b1c1d1c06e5d79547faea3f805785d67481b350f3f6a9cf6dc5/annotation_protocol-1.4.0.tar.gz", hash = "sha256:15d846a4984339bab6cbf80a44623219b8cb06b4f4fee0f22c31a255d16900f8", size = 8470, upload-time = "2026-01-19T08:48:27.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/8b/71a5e1392dd3aca7ffeef0c3b10ea9b0e62959b5f39889702a06e11eda96/annotation_protocol-1.4.0-py3-none-any.whl", hash = "sha256:6fc66f1506f015db16fdd50fad18520cbb126a7902b27257c9fa521eb5efec60", size = 7834, upload-time = "2026-01-19T08:48:25.848Z" }, -] - [[package]] name = "anyio" version = "4.14.1" @@ -876,10 +867,9 @@ wheels = [ [[package]] name = "dimos" -version = "0.0.12" +version = "0.0.13" source = { editable = "../../" } dependencies = [ - { name = "annotation-protocol" }, { name = "bleak" }, { name = "cryptography" }, { name = "dimos-lcm" }, @@ -912,13 +902,13 @@ dependencies = [ { name = "textual-serve" }, { name = "toolz" }, { name = "typer" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "websocket-client" }, ] [package.metadata] requires-dist = [ { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'" }, - { name = "annotation-protocol", specifier = ">=1.4.0" }, { name = "bleak", specifier = ">=3.0.2" }, { name = "can-motor-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'", specifier = ">=0.0.2" }, { name = "catkin-pkg", marker = "extra == 'grasp'", specifier = ">=1.1.0" }, @@ -927,7 +917,7 @@ requires-dist = [ { name = "cupy-cuda12x", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = "==13.6.0" }, { name = "cyclonedds", marker = "extra == 'dds'", specifier = ">=0.10.5" }, { name = "cyclonedds", marker = "extra == 'unitree-dds'", specifier = ">=0.10.5" }, - { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "grasp", "manipulation", "misc", "perception", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, + { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "grasp", "learning", "manipulation", "manipulation-toppra", "misc", "perception", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, { name = "dimos", extras = ["agents", "web", "perception", "visualization"], marker = "extra == 'base'" }, { name = "dimos", extras = ["base", "mapping"], marker = "extra == 'unitree'" }, { name = "dimos", extras = ["unitree"], marker = "extra == 'unitree-dds'" }, @@ -944,6 +934,7 @@ requires-dist = [ { name = "gdown", marker = "extra == 'misc'", specifier = ">=5.2.2" }, { name = "googlemaps", marker = "extra == 'misc'", specifier = ">=4.10.0" }, { name = "gtsam-extended", marker = "extra == 'mapping'", specifier = ">=4.3a1.post1" }, + { name = "h5py", marker = "extra == 'learning'" }, { name = "hydra-core", marker = "extra == 'perception'", specifier = ">=1.3.0" }, { name = "ipykernel", marker = "extra == 'misc'" }, { name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.6" }, @@ -972,6 +963,7 @@ requires-dist = [ { name = "openai", marker = "extra == 'agents'" }, { name = "opencv-contrib-python", marker = "extra == 'apriltag'", specifier = "==4.10.0.84" }, { name = "opencv-python" }, + { name = "pandas", marker = "extra == 'learning'" }, { name = "pillow", marker = "extra == 'perception'" }, { name = "pin", specifier = ">=3.3.0" }, { name = "pin-pink", marker = "extra == 'manipulation'", specifier = ">=4.2.0" }, @@ -982,6 +974,7 @@ requires-dist = [ { name = "portal", marker = "extra == 'misc'" }, { name = "protobuf", specifier = ">=6.33.5,<7" }, { name = "psutil", specifier = ">=7.0.0" }, + { name = "pyarrow", marker = "extra == 'learning'" }, { name = "pycollada", marker = "extra == 'manipulation'" }, { name = "pydantic" }, { name = "pydantic-settings", specifier = ">=2.11.0,<3" }, @@ -998,7 +991,8 @@ requires-dist = [ { name = "reportlab", marker = "extra == 'apriltag'", specifier = ">=4.5.0" }, { name = "rerun-sdk", specifier = "==0.32.0a1" }, { name = "rerun-sdk", marker = "extra == 'visualization'", specifier = "==0.32.0a1" }, - { name = "roboplan", extras = ["manipulation"], marker = "extra == 'manipulation'", specifier = ">=0.0.100" }, + { name = "roboplan", marker = "extra == 'manipulation'", specifier = ">=0.0.100" }, + { name = "roboplan", marker = "extra == 'manipulation-toppra'", specifier = ">=0.0.100" }, { name = "scipy", specifier = ">=1.15.1" }, { name = "sortedcontainers", specifier = "==2.4.0" }, { name = "sounddevice", marker = "extra == 'agents'" }, @@ -1017,7 +1011,7 @@ requires-dist = [ { name = "transformers", extras = ["torch"], marker = "extra == 'perception'", specifier = ">=4.53.0,<4.54" }, { name = "trimesh", marker = "extra == 'manipulation'" }, { name = "typer", specifier = ">=0.19.2,<1" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'", specifier = ">=4.7" }, { name = "ultralytics", marker = "extra == 'perception'", specifier = ">=8.3.70" }, { name = "unitree-sdk2py-dimos", marker = "extra == 'unitree-dds'", specifier = ">=1.0.2" }, { name = "unitree-webrtc-connect", marker = "extra == 'unitree'", specifier = ">=2.1.2" }, @@ -1029,7 +1023,7 @@ requires-dist = [ { name = "xarm-python-sdk", marker = "extra == 'manipulation'", specifier = ">=1.17.0" }, { name = "xarm-python-sdk", marker = "extra == 'misc'", specifier = ">=1.17.0" }, ] -provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "grasp", "cpu", "cuda", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] +provides-extras = ["misc", "visualization", "learning", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "manipulation-toppra", "grasp", "cpu", "cuda", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] [package.metadata.requires-dev] autofix = [{ name = "ruff", specifier = "==0.14.3" }] @@ -1096,7 +1090,7 @@ project-deps = [ tests = [ { name = "chromadb", specifier = ">=1.0.0" }, { name = "coverage", specifier = ">=7.0" }, - { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu"] }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu", "learning", "manipulation-toppra"] }, { name = "dimos", extras = ["web", "visualization"] }, { name = "einops", specifier = ">=0.8.1" }, { name = "gdown", specifier = "==6.0.0" }, @@ -1142,7 +1136,7 @@ tests-self-hosted = [ { name = "chromadb", specifier = ">=1.0.0" }, { name = "coverage", specifier = ">=7.0" }, { name = "dimos", extras = ["agents", "perception", "manipulation", "sim", "unitree", "misc"] }, - { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu"] }, + { name = "dimos", extras = ["apriltag", "mapping", "drone", "cpu", "learning", "manipulation-toppra"] }, { name = "dimos", extras = ["web", "visualization"] }, { name = "einops", specifier = ">=0.8.1" }, { name = "gdown", specifier = "==6.0.0" }, From 6039dac4d20d2cbb4b5b0a16efb4c0baf470bbaa Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 6 Jul 2026 14:17:16 -0700 Subject: [PATCH 109/110] fix: stabilize manual grasp sim execution --- dimos/manipulation/manipulation_module.py | 4 ++-- dimos/perception/object_scene_registration.py | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 5df6f24c29..846b2dd64d 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -180,8 +180,8 @@ class ManipulationModuleConfig(ModuleConfig): trajectory_parametrization: TrajectoryParametrizationConfig = Field( default_factory=TrajectoryParametrizationConfig ) - execution_settle_timeout: float = 7.0 - execution_joint_tolerance: float = 0.04 + execution_settle_timeout: float = 15.0 + execution_joint_tolerance: float = 0.08 execution_poll_interval: float = 0.1 diff --git a/dimos/perception/object_scene_registration.py b/dimos/perception/object_scene_registration.py index d54943c340..ac1b976e57 100644 --- a/dimos/perception/object_scene_registration.py +++ b/dimos/perception/object_scene_registration.py @@ -368,6 +368,15 @@ def _process_3d_detections( color_image.ts, 0.1, ) + if camera_transform is None: + # The camera can be mounted under a moving robot link (for example + # world -> link7 -> wrist_camera_color_optical_frame). The image, + # robot-link TF, and camera-link TF publishers do not always share + # exactly the same timestamp, so a strict image-time chain lookup can + # miss even when the latest TF tree is valid. Falling back to the + # latest composed transform keeps the single-parent TF tree intact + # while allowing perception to proceed in simulation. + camera_transform = self.tf.get(self._target_frame, color_image.frame_id) if camera_transform is None: logger.info("Failed to lookup transform from camera frame to target frame") return From 032ee82834a3cf3e8078f2b7f6bbadc7a7f50167 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 6 Jul 2026 14:35:13 -0700 Subject: [PATCH 110/110] spec: archive manual agentic grasp demo --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/gpd-grasp-detection/spec.md | 0 .../manipulation-agentic-primitives/spec.md | 0 .../specs/manual-agentic-grasp-demo/spec.md | 0 .../tasks.md | 0 openspec/specs/gpd-grasp-detection/spec.md | 15 ++++++ .../manipulation-agentic-primitives/spec.md | 53 +++++++++++++++++++ .../specs/manual-agentic-grasp-demo/spec.md | 41 ++++++++++++++ 10 files changed, 109 insertions(+) rename openspec/changes/{manual-agentic-grasp-demo => archive/2026-07-06-manual-agentic-grasp-demo}/.openspec.yaml (100%) rename openspec/changes/{manual-agentic-grasp-demo => archive/2026-07-06-manual-agentic-grasp-demo}/design.md (100%) rename openspec/changes/{manual-agentic-grasp-demo => archive/2026-07-06-manual-agentic-grasp-demo}/proposal.md (100%) rename openspec/changes/{manual-agentic-grasp-demo => archive/2026-07-06-manual-agentic-grasp-demo}/specs/gpd-grasp-detection/spec.md (100%) rename openspec/changes/{manual-agentic-grasp-demo => archive/2026-07-06-manual-agentic-grasp-demo}/specs/manipulation-agentic-primitives/spec.md (100%) rename openspec/changes/{manual-agentic-grasp-demo => archive/2026-07-06-manual-agentic-grasp-demo}/specs/manual-agentic-grasp-demo/spec.md (100%) rename openspec/changes/{manual-agentic-grasp-demo => archive/2026-07-06-manual-agentic-grasp-demo}/tasks.md (100%) create mode 100644 openspec/specs/manual-agentic-grasp-demo/spec.md diff --git a/openspec/changes/manual-agentic-grasp-demo/.openspec.yaml b/openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/.openspec.yaml similarity index 100% rename from openspec/changes/manual-agentic-grasp-demo/.openspec.yaml rename to openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/.openspec.yaml diff --git a/openspec/changes/manual-agentic-grasp-demo/design.md b/openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/design.md similarity index 100% rename from openspec/changes/manual-agentic-grasp-demo/design.md rename to openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/design.md diff --git a/openspec/changes/manual-agentic-grasp-demo/proposal.md b/openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/proposal.md similarity index 100% rename from openspec/changes/manual-agentic-grasp-demo/proposal.md rename to openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/proposal.md diff --git a/openspec/changes/manual-agentic-grasp-demo/specs/gpd-grasp-detection/spec.md b/openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/specs/gpd-grasp-detection/spec.md similarity index 100% rename from openspec/changes/manual-agentic-grasp-demo/specs/gpd-grasp-detection/spec.md rename to openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/specs/gpd-grasp-detection/spec.md diff --git a/openspec/changes/manual-agentic-grasp-demo/specs/manipulation-agentic-primitives/spec.md b/openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/specs/manipulation-agentic-primitives/spec.md similarity index 100% rename from openspec/changes/manual-agentic-grasp-demo/specs/manipulation-agentic-primitives/spec.md rename to openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/specs/manipulation-agentic-primitives/spec.md diff --git a/openspec/changes/manual-agentic-grasp-demo/specs/manual-agentic-grasp-demo/spec.md b/openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/specs/manual-agentic-grasp-demo/spec.md similarity index 100% rename from openspec/changes/manual-agentic-grasp-demo/specs/manual-agentic-grasp-demo/spec.md rename to openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/specs/manual-agentic-grasp-demo/spec.md diff --git a/openspec/changes/manual-agentic-grasp-demo/tasks.md b/openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/tasks.md similarity index 100% rename from openspec/changes/manual-agentic-grasp-demo/tasks.md rename to openspec/changes/archive/2026-07-06-manual-agentic-grasp-demo/tasks.md diff --git a/openspec/specs/gpd-grasp-detection/spec.md b/openspec/specs/gpd-grasp-detection/spec.md index bd1474fd67..c7a01b448d 100644 --- a/openspec/specs/gpd-grasp-detection/spec.md +++ b/openspec/specs/gpd-grasp-detection/spec.md @@ -59,3 +59,18 @@ The system SHALL include a documented end-to-end xArm MuJoCo demo command that s #### Scenario: Demo outputs are observable - **WHEN** the GPD MuJoCo demo attempts grasp generation - **THEN** grasp poses, candidate/debug outputs, or an explicit empty-result message are visible through normal DimOS outputs, logs, or Rerun visualization + +### Requirement: GPD candidates can feed manual grasp execution demo +The GPD grasp detection workflow SHALL support an opt-in manual execution demo that consumes generated Grasp candidates through the agent-facing grasp-capable manipulation facade while preserving the existing candidate-generation-only demo behavior. + +#### Scenario: Existing GPD visualization demo remains non-executing +- **WHEN** the existing candidate-generation-only GPD MuJoCo demo runs +- **THEN** it MUST continue to stop before robot motion, gripper actuation, pick/place motion, or trajectory execution + +#### Scenario: Manual execution demo consumes generated candidates +- **WHEN** the manual execution demo runs and GPD returns one or more Grasp candidates for the configured Grasp target +- **THEN** the agent-facing grasp-capable facade MUST be able to cache those candidates for a subsequent `execute_grasp(candidate_index)` call + +#### Scenario: Empty GPD result is reported clearly +- **WHEN** GPD returns no usable Grasp candidates during the manual execution demo +- **THEN** the agent-facing sequence MUST report a clear empty-result failure before attempting `execute_grasp(...)` diff --git a/openspec/specs/manipulation-agentic-primitives/spec.md b/openspec/specs/manipulation-agentic-primitives/spec.md index 1cc235ead4..1175eb93a4 100644 --- a/openspec/specs/manipulation-agentic-primitives/spec.md +++ b/openspec/specs/manipulation-agentic-primitives/spec.md @@ -51,3 +51,56 @@ The system SHALL provide a script-hosted Robosuite validation path that calls th #### Scenario: Script-hosted artifacts - **WHEN** the Robosuite validation script completes or fails - **THEN** it MUST write artifacts describing the episode config, runtime description, resolved runtime plan, API call summary, motor trace, score when available, sidecar log, and cleanup status + +### Requirement: Grasp-capable agentic manipulation facade +The system SHALL provide a grasp-capable agent-facing manipulation facade separate from the universal manipulation facade for blueprints that include perception and grasp-generation dependencies. + +#### Scenario: Universal facade remains dependency-light +- **WHEN** a blueprint only provides the universal manipulation provider dependency +- **THEN** `AgenticManipulationModule` MUST remain usable without requiring object-scene registration, GPD, or grasp orchestration providers + +#### Scenario: Grasp facade exposes additional dependencies +- **WHEN** a blueprint wires the grasp-capable facade +- **THEN** the facade MUST be able to delegate object scanning, grasp generation, and motion/gripper execution through injected DimOS Spec/RPC dependencies + +### Requirement: Round 1 grasp demo skill surface +The grasp-capable facade SHALL expose the Round 1 agent-facing skills needed for the manual sim grasp demo and useful motion debugging. + +#### Scenario: Scene and grasp skills are available +- **WHEN** a caller inspects the grasp-capable facade skill schema +- **THEN** `scan_objects(...)`, `generate_grasps(...)`, and `execute_grasp(candidate_index=0)` MUST be available as skills + +#### Scenario: Motion and gripper skills are available +- **WHEN** a caller inspects the grasp-capable facade skill schema +- **THEN** `move_to_pose(...)`, `move_relative(...)`, `move_along_axis(...)`, `go_home()`, `open_gripper()`, `close_gripper()`, and `set_gripper(...)` MUST be available as skills + +### Requirement: Cached Grasp candidate execution +The grasp-capable facade SHALL execute a selected cached Grasp candidate without implicitly rescanning or regenerating grasps. + +#### Scenario: Execute selected cached candidate +- **WHEN** `generate_grasps(...)` has cached one or more Grasp candidates and the caller invokes `execute_grasp(candidate_index)` with a valid index +- **THEN** the facade MUST select that cached candidate and command a conservative execution sequence that opens the gripper, moves to pregrasp, moves to the grasp pose, closes the gripper, and lifts or retracts + +#### Scenario: Execute fails without cached candidates +- **WHEN** a caller invokes `execute_grasp(...)` before any Grasp candidates are cached +- **THEN** the facade MUST return a clear failure explaining that `generate_grasps(...)` must be called first +- **AND** it MUST NOT implicitly call `scan_objects(...)` or `generate_grasps(...)` + +#### Scenario: Execute rejects invalid candidate index +- **WHEN** a caller invokes `execute_grasp(candidate_index)` with an index outside the cached Grasp candidate range +- **THEN** the facade MUST return a clear invalid-input failure without commanding robot motion + +### Requirement: World-frame relative motion skills +The agent-facing relative motion skills SHALL default to world-frame Cartesian deltas for Round 1. + +#### Scenario: Relative motion defaults to world frame +- **WHEN** a caller invokes `move_relative(dx, dy, dz)` without a frame argument +- **THEN** the requested translation MUST be interpreted in the world/base frame + +#### Scenario: Axis motion defaults to world frame +- **WHEN** a caller invokes `move_along_axis(axis, distance)` without a frame argument +- **THEN** the requested axis motion MUST be interpreted in the world/base frame + +#### Scenario: Unsupported relative frame fails clearly +- **WHEN** a caller invokes relative motion with a frame that the underlying planner does not support +- **THEN** the facade MUST return a clear unsupported-frame failure instead of silently changing frames diff --git a/openspec/specs/manual-agentic-grasp-demo/spec.md b/openspec/specs/manual-agentic-grasp-demo/spec.md new file mode 100644 index 0000000000..fcb12eb011 --- /dev/null +++ b/openspec/specs/manual-agentic-grasp-demo/spec.md @@ -0,0 +1,41 @@ +## Purpose + +Define the deterministic manual MuJoCo grasp demo path that composes registered-object scanning, GPD grasp generation, and agent-facing grasp execution without an autonomous LLM loop. + +## Requirements + +### Requirement: Manual agent-facing grasp pipeline demo +The system SHALL provide a deterministic MuJoCo demo path that proves manual agent-facing tool calls can compose registered-object scanning, GPD grasp generation, and grasp execution without requiring an autonomous LLM loop. + +#### Scenario: Manual sequence completes through tool surface +- **WHEN** the manual grasp demo is running with the configured sphere Grasp target visible +- **THEN** a caller MUST be able to invoke `scan_objects("sphere")`, `generate_grasps("sphere")`, and `execute_grasp(0)` through the agent-facing tool surface +- **AND** the sequence MUST route through the same facade methods intended for future agent/MCP callers + +#### Scenario: Demo does not require autonomous LLM execution +- **WHEN** the required manual grasp demo or smoke test runs +- **THEN** it MUST NOT require an `McpClient`, model API key, or autonomous LLM decision loop + +### Requirement: Gated sim smoke validation +The system SHALL include an opt-in MuJoCo/self-hosted smoke validation for the manual grasp pipeline. + +#### Scenario: Smoke test validates pipeline completion +- **WHEN** the gated smoke test runs in an environment with the required MuJoCo and GPD runtime dependencies prepared +- **THEN** it MUST fail if the configured Grasp target cannot be registered +- **AND** it MUST fail if grasp generation returns no cached Grasp candidates +- **AND** it MUST fail if `execute_grasp(0)` does not complete its motion/gripper sequence successfully + +#### Scenario: Smoke test does not require object lift success +- **WHEN** `execute_grasp(0)` completes in the smoke test +- **THEN** the test MUST NOT require a MuJoCo contact/object-lift assertion as its pass/fail condition + +### Requirement: Manual demo documentation +The system SHALL document how to prepare and run the manual agent-facing grasp demo and how to assess visual demo quality. + +#### Scenario: User follows manual command sequence +- **WHEN** a user follows the manual demo documentation after preparing the required runtime +- **THEN** the documentation MUST provide the command sequence for starting the demo and invoking the manual tool calls + +#### Scenario: User checks Rerun visual outputs +- **WHEN** a user runs the manual demo with visualization enabled +- **THEN** the documentation MUST describe the expected Rerun checklist: registered Grasp target, GPD Grasp candidates, robot approach, gripper close, and lift/retract motion